]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'api-endpoint-users' into users_api
authorDan Brown <redacted>
Thu, 3 Feb 2022 11:38:55 +0000 (11:38 +0000)
committerDan Brown <redacted>
Thu, 3 Feb 2022 11:38:55 +0000 (11:38 +0000)
1043 files changed:
.env.example
.env.example.complete
.github/ISSUE_TEMPLATE/api_request.md [deleted file]
.github/ISSUE_TEMPLATE/api_request.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/bug_report.md [deleted file]
.github/ISSUE_TEMPLATE/bug_report.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/feature_request.md [deleted file]
.github/ISSUE_TEMPLATE/feature_request.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/language_request.md [deleted file]
.github/ISSUE_TEMPLATE/language_request.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/support_request.yml [new file with mode: 0644]
.github/SECURITY.md [new file with mode: 0644]
.github/translators.txt
.github/workflows/phpstan.yml [new file with mode: 0644]
.github/workflows/phpunit.yml
.github/workflows/test-migrations.yml
.gitignore
LICENSE
app/Actions/Activity.php
app/Actions/ActivityLogger.php [new file with mode: 0644]
app/Actions/ActivityQueries.php [new file with mode: 0644]
app/Actions/ActivityService.php [deleted file]
app/Actions/ActivityType.php
app/Actions/Comment.php
app/Actions/CommentRepo.php
app/Actions/DispatchWebhookJob.php [new file with mode: 0644]
app/Actions/Favourite.php [new file with mode: 0644]
app/Actions/Tag.php
app/Actions/TagRepo.php
app/Actions/View.php
app/Actions/ViewService.php [deleted file]
app/Actions/Webhook.php [new file with mode: 0644]
app/Actions/WebhookTrackedEvent.php [new file with mode: 0644]
app/Api/ApiDocsGenerator.php
app/Api/ApiToken.php
app/Api/ApiTokenGuard.php
app/Api/ListingResponseBuilder.php
app/Application.php
app/Auth/Access/EmailConfirmationService.php
app/Auth/Access/ExternalBaseUserProvider.php
app/Auth/Access/GroupSyncService.php [moved from app/Auth/Access/ExternalAuthService.php with 86% similarity]
app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php [moved from app/Auth/Access/Guards/Saml2SessionGuard.php with 85% similarity]
app/Auth/Access/Guards/ExternalBaseSessionGuard.php
app/Auth/Access/Guards/LdapSessionGuard.php
app/Auth/Access/Ldap.php
app/Auth/Access/LdapService.php
app/Auth/Access/LoginService.php [new file with mode: 0644]
app/Auth/Access/Mfa/BackupCodeService.php [new file with mode: 0644]
app/Auth/Access/Mfa/MfaSession.php [new file with mode: 0644]
app/Auth/Access/Mfa/MfaValue.php [new file with mode: 0644]
app/Auth/Access/Mfa/TotpService.php [new file with mode: 0644]
app/Auth/Access/Mfa/TotpValidationRule.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcAccessToken.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcIdToken.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcInvalidKeyException.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcInvalidTokenException.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcJwtSigningKey.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcOAuthProvider.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcProviderSettings.php [new file with mode: 0644]
app/Auth/Access/Oidc/OidcService.php [new file with mode: 0644]
app/Auth/Access/RegistrationService.php
app/Auth/Access/Saml2Service.php
app/Auth/Access/SocialAuthService.php
app/Auth/Access/UserInviteService.php
app/Auth/Access/UserTokenService.php
app/Auth/Permissions/EntityPermission.php
app/Auth/Permissions/JointPermission.php
app/Auth/Permissions/PermissionService.php
app/Auth/Permissions/PermissionsRepo.php
app/Auth/Permissions/RolePermission.php
app/Auth/Role.php
app/Auth/SocialAccount.php
app/Auth/User.php
app/Auth/UserRepo.php
app/Config/api.php
app/Config/app.php [changed mode: 0755->0644]
app/Config/auth.php
app/Config/broadcasting.php
app/Config/cache.php
app/Config/clockwork.php [new file with mode: 0644]
app/Config/database.php
app/Config/debugbar.php
app/Config/dompdf.php
app/Config/filesystems.php
app/Config/hashing.php
app/Config/logging.php
app/Config/mail.php
app/Config/oidc.php [new file with mode: 0644]
app/Config/queue.php
app/Config/saml2.php
app/Config/services.php
app/Config/session.php
app/Config/setting-defaults.php
app/Config/snappy.php
app/Console/Commands/CleanupImages.php
app/Console/Commands/ClearViews.php
app/Console/Commands/CopyShelfPermissions.php
app/Console/Commands/CreateAdmin.php
app/Console/Commands/DeleteUsers.php
app/Console/Commands/RegenerateSearch.php
app/Console/Commands/ResetMfa.php [new file with mode: 0644]
app/Console/Commands/UpdateUrl.php
app/Console/Commands/UpgradeDatabaseEncoding.php
app/Console/Kernel.php
app/Entities/BreadcrumbsViewComposer.php
app/Entities/EntityProvider.php
app/Entities/Models/Book.php
app/Entities/Models/BookChild.php
app/Entities/Models/Bookshelf.php
app/Entities/Models/Chapter.php
app/Entities/Models/Deletion.php
app/Entities/Models/Entity.php
app/Entities/Models/HasCoverImage.php
app/Entities/Models/Page.php
app/Entities/Models/PageRevision.php
app/Entities/Models/SearchTerm.php
app/Entities/Queries/EntityQuery.php [new file with mode: 0644]
app/Entities/Queries/Popular.php [new file with mode: 0644]
app/Entities/Queries/RecentlyViewed.php [new file with mode: 0644]
app/Entities/Queries/TopFavourites.php [new file with mode: 0644]
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/BookContents.php
app/Entities/Tools/BookSortMap.php [new file with mode: 0644]
app/Entities/Tools/BookSortMapItem.php [new file with mode: 0644]
app/Entities/Tools/Cloner.php [new file with mode: 0644]
app/Entities/Tools/ExportFormatter.php
app/Entities/Tools/Markdown/CustomListItemRenderer.php [new file with mode: 0644]
app/Entities/Tools/Markdown/CustomParagraphConverter.php [new file with mode: 0644]
app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php
app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php
app/Entities/Tools/Markdown/HtmlToMarkdown.php [new file with mode: 0644]
app/Entities/Tools/NextPreviousContentLocator.php [new file with mode: 0644]
app/Entities/Tools/PageContent.php
app/Entities/Tools/PageEditActivity.php
app/Entities/Tools/PdfGenerator.php [new file with mode: 0644]
app/Entities/Tools/PermissionsUpdater.php
app/Entities/Tools/SearchIndex.php
app/Entities/Tools/SearchOptions.php
app/Entities/Tools/SearchResultsFormatter.php [new file with mode: 0644]
app/Entities/Tools/SearchRunner.php
app/Entities/Tools/ShelfContext.php
app/Entities/Tools/SiblingFetcher.php
app/Entities/Tools/SlugGenerator.php
app/Entities/Tools/TrashCan.php
app/Exceptions/ApiAuthException.php
app/Exceptions/ConfirmationEmailException.php
app/Exceptions/FileUploadException.php
app/Exceptions/Handler.php
app/Exceptions/HttpFetchException.php
app/Exceptions/ImageUploadException.php
app/Exceptions/JsonDebugException.php
app/Exceptions/LdapException.php
app/Exceptions/LoginAttemptEmailNeededException.php
app/Exceptions/LoginAttemptException.php
app/Exceptions/MoveOperationException.php
app/Exceptions/NotFoundException.php
app/Exceptions/NotifyException.php
app/Exceptions/OpenIdConnectException.php [new file with mode: 0644]
app/Exceptions/PermissionsException.php
app/Exceptions/PrettyException.php
app/Exceptions/SamlException.php
app/Exceptions/SocialDriverNotConfigured.php
app/Exceptions/SocialSignInAccountNotUsed.php
app/Exceptions/SocialSignInException.php
app/Exceptions/SortOperationException.php [deleted file]
app/Exceptions/StoppedAuthenticationException.php [new file with mode: 0644]
app/Exceptions/UnauthorizedException.php
app/Exceptions/UserRegistrationException.php
app/Exceptions/UserTokenExpiredException.php
app/Exceptions/UserTokenNotFoundException.php
app/Exceptions/UserUpdateException.php
app/Exceptions/WhoopsBookStackPrettyHandler.php [new file with mode: 0644]
app/Facades/Activity.php
app/Facades/Images.php [deleted file]
app/Facades/Permissions.php
app/Facades/Theme.php
app/Facades/Views.php [deleted file]
app/Http/Controllers/Api/ApiController.php
app/Http/Controllers/Api/ApiDocsController.php
app/Http/Controllers/Api/AttachmentApiController.php [new file with mode: 0644]
app/Http/Controllers/Api/BookApiController.php
app/Http/Controllers/Api/BookExportApiController.php
app/Http/Controllers/Api/BookshelfApiController.php
app/Http/Controllers/Api/ChapterApiController.php
app/Http/Controllers/Api/ChapterExportApiController.php
app/Http/Controllers/Api/PageApiController.php
app/Http/Controllers/Api/PageExportApiController.php
app/Http/Controllers/Api/SearchApiController.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/AuditLogController.php
app/Http/Controllers/Auth/ConfirmEmailController.php
app/Http/Controllers/Auth/ForgotPasswordController.php
app/Http/Controllers/Auth/HandlesPartialLogins.php [new file with mode: 0644]
app/Http/Controllers/Auth/LoginController.php
app/Http/Controllers/Auth/MfaBackupCodesController.php [new file with mode: 0644]
app/Http/Controllers/Auth/MfaController.php [new file with mode: 0644]
app/Http/Controllers/Auth/MfaTotpController.php [new file with mode: 0644]
app/Http/Controllers/Auth/OidcController.php [new file with mode: 0644]
app/Http/Controllers/Auth/RegisterController.php
app/Http/Controllers/Auth/ResetPasswordController.php
app/Http/Controllers/Auth/Saml2Controller.php
app/Http/Controllers/Auth/SocialController.php
app/Http/Controllers/Auth/UserInviteController.php
app/Http/Controllers/BookController.php
app/Http/Controllers/BookExportController.php
app/Http/Controllers/BookSortController.php
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/ChapterExportController.php
app/Http/Controllers/CommentController.php
app/Http/Controllers/Controller.php
app/Http/Controllers/FavouriteController.php [new file with mode: 0644]
app/Http/Controllers/HomeController.php
app/Http/Controllers/Images/DrawioImageController.php
app/Http/Controllers/Images/GalleryImageController.php
app/Http/Controllers/Images/ImageController.php
app/Http/Controllers/MaintenanceController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/PageExportController.php
app/Http/Controllers/PageRevisionController.php
app/Http/Controllers/PageTemplateController.php
app/Http/Controllers/RecycleBinController.php
app/Http/Controllers/RoleController.php
app/Http/Controllers/SearchController.php
app/Http/Controllers/SettingController.php
app/Http/Controllers/StatusController.php
app/Http/Controllers/TagController.php
app/Http/Controllers/UserApiTokenController.php
app/Http/Controllers/UserController.php
app/Http/Controllers/UserProfileController.php
app/Http/Controllers/UserSearchController.php
app/Http/Controllers/WebhookController.php [new file with mode: 0644]
app/Http/Kernel.php
app/Http/Middleware/ApiAuthenticate.php
app/Http/Middleware/ApplyCspRules.php [new file with mode: 0644]
app/Http/Middleware/Authenticate.php
app/Http/Middleware/AuthenticatedOrPendingMfa.php [new file with mode: 0644]
app/Http/Middleware/CheckEmailConfirmed.php [new file with mode: 0644]
app/Http/Middleware/CheckGuard.php
app/Http/Middleware/CheckUserHasPermission.php [new file with mode: 0644]
app/Http/Middleware/ChecksForEmailConfirmation.php [deleted file]
app/Http/Middleware/ControlIframeSecurity.php [deleted file]
app/Http/Middleware/Localization.php
app/Http/Middleware/PermissionMiddleware.php [deleted file]
app/Http/Middleware/PreventAuthenticatedResponseCaching.php [new file with mode: 0644]
app/Http/Middleware/PreventRequestsDuringMaintenance.php [moved from app/Http/Middleware/CheckForMaintenanceMode.php with 58% similarity]
app/Http/Middleware/RedirectIfAuthenticated.php
app/Http/Middleware/RunThemeActions.php
app/Http/Middleware/ThrottleApiRequests.php
app/Http/Middleware/TrustHosts.php [new file with mode: 0644]
app/Http/Middleware/TrustProxies.php
app/Http/Middleware/VerifyCsrfToken.php
app/Http/Request.php
app/Interfaces/Deletable.php [new file with mode: 0644]
app/Interfaces/Favouritable.php [new file with mode: 0644]
app/Interfaces/Sluggable.php
app/Interfaces/Viewable.php [new file with mode: 0644]
app/Model.php
app/Notifications/ConfirmEmail.php
app/Notifications/MailNotification.php
app/Notifications/ResetPassword.php
app/Notifications/TestEmail.php
app/Notifications/UserInvite.php
app/Providers/AppServiceProvider.php
app/Providers/AuthServiceProvider.php
app/Providers/CustomFacadeProvider.php
app/Providers/CustomValidationServiceProvider.php
app/Providers/EventServiceProvider.php
app/Providers/PaginationServiceProvider.php
app/Providers/RouteServiceProvider.php
app/Providers/ThemeServiceProvider.php
app/Providers/TranslationServiceProvider.php
app/Settings/Setting.php
app/Settings/SettingService.php
app/Theming/CustomHtmlHeadContentProvider.php [new file with mode: 0644]
app/Theming/ThemeEvents.php
app/Theming/ThemeService.php
app/Traits/HasCreatorAndUpdater.php
app/Traits/HasOwner.php
app/Translation/FileLoader.php
app/Uploads/Attachment.php
app/Uploads/AttachmentService.php
app/Uploads/HttpFetcher.php
app/Uploads/Image.php
app/Uploads/ImageRepo.php
app/Uploads/ImageService.php
app/Uploads/UserAvatars.php
app/Util/CspService.php [new file with mode: 0644]
app/Util/HtmlContentFilter.php
app/Util/HtmlNonceApplicator.php [new file with mode: 0644]
app/Util/WebSafeMimeSniffer.php [new file with mode: 0644]
app/helpers.php
bootstrap/phpstan.php [new file with mode: 0644]
composer.json
composer.lock
database/factories/Actions/CommentFactory.php [new file with mode: 0644]
database/factories/Actions/TagFactory.php [new file with mode: 0644]
database/factories/Actions/WebhookFactory.php [new file with mode: 0644]
database/factories/Actions/WebhookTrackedEventFactory.php [new file with mode: 0644]
database/factories/Auth/RoleFactory.php [new file with mode: 0644]
database/factories/Auth/UserFactory.php [new file with mode: 0644]
database/factories/Entities/Models/BookFactory.php [new file with mode: 0644]
database/factories/Entities/Models/BookshelfFactory.php [new file with mode: 0644]
database/factories/Entities/Models/ChapterFactory.php [new file with mode: 0644]
database/factories/Entities/Models/PageFactory.php [new file with mode: 0644]
database/factories/ModelFactory.php [deleted file]
database/factories/Uploads/ImageFactory.php [new file with mode: 0644]
database/migrations/2014_10_12_000000_create_users_table.php
database/migrations/2014_10_12_100000_create_password_resets_table.php
database/migrations/2015_07_12_114933_create_books_table.php
database/migrations/2015_07_12_190027_create_pages_table.php
database/migrations/2015_07_13_172121_create_images_table.php
database/migrations/2015_07_27_172342_create_chapters_table.php
database/migrations/2015_08_08_200447_add_users_to_entities.php
database/migrations/2015_08_09_093534_create_page_revisions_table.php
database/migrations/2015_08_16_142133_create_activities_table.php
database/migrations/2015_08_29_105422_add_roles_and_permissions.php
database/migrations/2015_08_30_125859_create_settings_table.php
database/migrations/2015_08_31_175240_add_search_indexes.php
database/migrations/2015_09_04_165821_create_social_accounts_table.php
database/migrations/2015_09_05_164707_add_email_confirmation_table.php
database/migrations/2015_11_21_145609_create_views_table.php
database/migrations/2015_11_26_221857_add_entity_indexes.php
database/migrations/2015_12_05_145049_fulltext_weighting.php
database/migrations/2015_12_07_195238_add_image_upload_types.php
database/migrations/2015_12_09_195748_add_user_avatars.php
database/migrations/2016_01_11_210908_add_external_auth_to_users.php
database/migrations/2016_02_25_184030_add_slug_to_revisions.php
database/migrations/2016_02_27_120329_update_permissions_and_roles.php
database/migrations/2016_02_28_084200_add_entity_access_controls.php
database/migrations/2016_03_09_203143_add_page_revision_types.php
database/migrations/2016_03_13_082138_add_page_drafts.php
database/migrations/2016_03_25_123157_add_markdown_support.php
database/migrations/2016_04_09_100730_add_view_permissions_to_roles.php
database/migrations/2016_04_20_192649_create_joint_permissions_table.php
database/migrations/2016_05_06_185215_create_tags_table.php
database/migrations/2016_07_07_181521_add_summary_to_page_revisions.php
database/migrations/2016_09_29_101449_remove_hidden_roles.php
database/migrations/2016_10_09_142037_create_attachments_table.php
database/migrations/2017_01_21_163556_create_cache_table.php
database/migrations/2017_01_21_163602_create_sessions_table.php
database/migrations/2017_03_19_091553_create_search_index_table.php
database/migrations/2017_04_20_185112_add_revision_counts.php
database/migrations/2017_08_01_130541_create_comments_table.php
database/migrations/2017_08_29_102650_add_cover_image_display.php
database/migrations/2018_07_15_173514_add_role_external_auth_id.php
database/migrations/2018_08_04_115700_create_bookshelves_table.php
database/migrations/2019_07_07_112515_add_template_support.php
database/migrations/2019_08_17_140214_add_user_invites_table.php
database/migrations/2019_12_29_120917_add_api_auth.php
database/migrations/2020_08_04_131052_remove_role_name_field.php
database/migrations/2020_09_19_094251_add_activity_indexes.php
database/migrations/2020_09_27_210059_add_entity_soft_deletes.php
database/migrations/2020_11_07_232321_simplify_activities_table.php
database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php
database/migrations/2021_05_15_173110_create_favourites_table.php [new file with mode: 0644]
database/migrations/2021_06_30_173111_create_mfa_values_table.php [new file with mode: 0644]
database/migrations/2021_07_03_085038_add_mfa_enforced_to_roles_table.php [new file with mode: 0644]
database/migrations/2021_08_28_161743_add_export_role_permission.php [new file with mode: 0644]
database/migrations/2021_09_26_044614_add_activities_ip_column.php [new file with mode: 0644]
database/migrations/2021_11_26_070438_add_index_for_user_ip.php [new file with mode: 0644]
database/migrations/2021_12_07_111343_create_webhooks_table.php [new file with mode: 0644]
database/migrations/2021_12_13_152024_create_jobs_table.php [new file with mode: 0644]
database/migrations/2021_12_13_152120_create_failed_jobs_table.php [new file with mode: 0644]
database/migrations/2022_01_03_154041_add_webhooks_timeout_error_columns.php [new file with mode: 0644]
database/seeders/.gitkeep [moved from database/seeds/.gitkeep with 100% similarity]
database/seeders/DatabaseSeeder.php [moved from database/seeds/DatabaseSeeder.php with 92% similarity]
database/seeders/DummyContentSeeder.php [moved from database/seeds/DummyContentSeeder.php with 60% similarity]
database/seeders/LargeContentSeeder.php [new file with mode: 0644]
database/seeds/LargeContentSeeder.php [deleted file]
dev/api/requests/attachments-create.json [new file with mode: 0644]
dev/api/requests/attachments-update.json [new file with mode: 0644]
dev/api/requests/search-all.http [new file with mode: 0644]
dev/api/responses/attachments-create.json [new file with mode: 0644]
dev/api/responses/attachments-list.json [new file with mode: 0644]
dev/api/responses/attachments-read.json [new file with mode: 0644]
dev/api/responses/attachments-update.json [new file with mode: 0644]
dev/api/responses/books-create.json
dev/api/responses/books-list.json
dev/api/responses/books-read.json
dev/api/responses/books-update.json
dev/api/responses/chapters-create.json
dev/api/responses/chapters-list.json
dev/api/responses/chapters-read.json
dev/api/responses/chapters-update.json
dev/api/responses/pages-create.json
dev/api/responses/pages-list.json
dev/api/responses/pages-read.json
dev/api/responses/pages-update.json
dev/api/responses/search-all.json [new file with mode: 0644]
dev/api/responses/shelves-create.json
dev/api/responses/shelves-list.json
dev/api/responses/shelves-read.json
dev/api/responses/shelves-update.json
dev/docker/Dockerfile
dev/docker/php/conf.d/xdebug.ini [new file with mode: 0644]
dev/docs/logical-theme-system.md
dev/docs/visual-theme-system.md
docker-compose.yml
package-lock.json
package.json
phpcs.xml [deleted file]
phpstan.neon.dist [new file with mode: 0644]
phpunit.xml
public/index.php
public/loading_error.png [new file with mode: 0644]
readme.md
resources/icons/auth/discord.svg
resources/icons/auth/facebook.svg
resources/icons/auth/slack.svg
resources/icons/info-filled.svg
resources/icons/leaderboard.svg [new file with mode: 0644]
resources/icons/oidc.svg [new file with mode: 0644]
resources/icons/star-circle.svg
resources/icons/star-outline.svg [new file with mode: 0644]
resources/icons/tag.svg
resources/icons/webhooks.svg [new file with mode: 0644]
resources/js/components/attachments-list.js [new file with mode: 0644]
resources/js/components/dropdown-search.js
resources/js/components/dropzone.js
resources/js/components/editor-toolbox.js
resources/js/components/entity-selector-popup.js
resources/js/components/image-manager.js
resources/js/components/index.js
resources/js/components/markdown-editor.js
resources/js/components/page-editor.js
resources/js/components/submit-on-change.js
resources/js/components/user-select.js
resources/js/components/webhook-events.js [new file with mode: 0644]
resources/js/components/wysiwyg-editor.js
resources/js/services/code.js
resources/js/services/drawio.js
resources/lang/ar/activities.php
resources/lang/ar/auth.php
resources/lang/ar/common.php
resources/lang/ar/components.php
resources/lang/ar/entities.php
resources/lang/ar/errors.php
resources/lang/ar/settings.php
resources/lang/ar/validation.php
resources/lang/bg/activities.php
resources/lang/bg/auth.php
resources/lang/bg/common.php
resources/lang/bg/components.php
resources/lang/bg/entities.php
resources/lang/bg/errors.php
resources/lang/bg/settings.php
resources/lang/bg/validation.php
resources/lang/bs/activities.php
resources/lang/bs/auth.php
resources/lang/bs/common.php
resources/lang/bs/entities.php
resources/lang/bs/errors.php
resources/lang/bs/settings.php
resources/lang/bs/validation.php
resources/lang/ca/activities.php
resources/lang/ca/auth.php
resources/lang/ca/common.php
resources/lang/ca/entities.php
resources/lang/ca/errors.php
resources/lang/ca/settings.php
resources/lang/ca/validation.php
resources/lang/cs/activities.php
resources/lang/cs/auth.php
resources/lang/cs/common.php
resources/lang/cs/components.php
resources/lang/cs/entities.php
resources/lang/cs/errors.php
resources/lang/cs/passwords.php
resources/lang/cs/settings.php
resources/lang/cs/validation.php
resources/lang/da/activities.php
resources/lang/da/auth.php
resources/lang/da/common.php
resources/lang/da/entities.php
resources/lang/da/errors.php
resources/lang/da/settings.php
resources/lang/da/validation.php
resources/lang/de/activities.php
resources/lang/de/auth.php
resources/lang/de/common.php
resources/lang/de/entities.php
resources/lang/de/errors.php
resources/lang/de/passwords.php
resources/lang/de/settings.php
resources/lang/de/validation.php
resources/lang/de_informal/activities.php
resources/lang/de_informal/auth.php
resources/lang/de_informal/common.php
resources/lang/de_informal/entities.php
resources/lang/de_informal/errors.php
resources/lang/de_informal/settings.php
resources/lang/de_informal/validation.php
resources/lang/en/activities.php
resources/lang/en/auth.php
resources/lang/en/common.php
resources/lang/en/entities.php
resources/lang/en/errors.php
resources/lang/en/settings.php
resources/lang/en/validation.php
resources/lang/es/activities.php
resources/lang/es/auth.php
resources/lang/es/common.php
resources/lang/es/entities.php
resources/lang/es/errors.php
resources/lang/es/settings.php
resources/lang/es/validation.php
resources/lang/es_AR/activities.php
resources/lang/es_AR/auth.php
resources/lang/es_AR/common.php
resources/lang/es_AR/entities.php
resources/lang/es_AR/errors.php
resources/lang/es_AR/settings.php
resources/lang/es_AR/validation.php
resources/lang/et/activities.php [new file with mode: 0644]
resources/lang/et/auth.php [new file with mode: 0644]
resources/lang/et/common.php [new file with mode: 0644]
resources/lang/et/components.php [new file with mode: 0644]
resources/lang/et/entities.php [new file with mode: 0644]
resources/lang/et/errors.php [new file with mode: 0644]
resources/lang/et/pagination.php [new file with mode: 0644]
resources/lang/et/passwords.php [new file with mode: 0644]
resources/lang/et/settings.php [new file with mode: 0644]
resources/lang/et/validation.php [new file with mode: 0644]
resources/lang/fa/activities.php
resources/lang/fa/auth.php
resources/lang/fa/common.php
resources/lang/fa/components.php
resources/lang/fa/entities.php
resources/lang/fa/errors.php
resources/lang/fa/pagination.php
resources/lang/fa/passwords.php
resources/lang/fa/settings.php
resources/lang/fa/validation.php
resources/lang/fr/activities.php
resources/lang/fr/auth.php
resources/lang/fr/common.php
resources/lang/fr/components.php
resources/lang/fr/entities.php
resources/lang/fr/errors.php
resources/lang/fr/passwords.php
resources/lang/fr/settings.php
resources/lang/fr/validation.php
resources/lang/he/activities.php
resources/lang/he/auth.php
resources/lang/he/common.php
resources/lang/he/entities.php
resources/lang/he/errors.php
resources/lang/he/settings.php
resources/lang/he/validation.php
resources/lang/hr/activities.php [new file with mode: 0644]
resources/lang/hr/auth.php [new file with mode: 0644]
resources/lang/hr/common.php [new file with mode: 0644]
resources/lang/hr/components.php [new file with mode: 0644]
resources/lang/hr/entities.php [new file with mode: 0644]
resources/lang/hr/errors.php [new file with mode: 0644]
resources/lang/hr/pagination.php [new file with mode: 0644]
resources/lang/hr/passwords.php [new file with mode: 0644]
resources/lang/hr/settings.php [new file with mode: 0644]
resources/lang/hr/validation.php [new file with mode: 0644]
resources/lang/hu/activities.php
resources/lang/hu/auth.php
resources/lang/hu/common.php
resources/lang/hu/entities.php
resources/lang/hu/errors.php
resources/lang/hu/settings.php
resources/lang/hu/validation.php
resources/lang/id/activities.php
resources/lang/id/auth.php
resources/lang/id/common.php
resources/lang/id/entities.php
resources/lang/id/errors.php
resources/lang/id/settings.php
resources/lang/id/validation.php
resources/lang/it/activities.php
resources/lang/it/auth.php
resources/lang/it/common.php
resources/lang/it/components.php
resources/lang/it/entities.php
resources/lang/it/errors.php
resources/lang/it/passwords.php
resources/lang/it/settings.php
resources/lang/it/validation.php
resources/lang/ja/activities.php
resources/lang/ja/auth.php
resources/lang/ja/common.php
resources/lang/ja/components.php
resources/lang/ja/entities.php
resources/lang/ja/errors.php
resources/lang/ja/passwords.php
resources/lang/ja/settings.php
resources/lang/ja/validation.php
resources/lang/ko/activities.php
resources/lang/ko/auth.php
resources/lang/ko/common.php
resources/lang/ko/entities.php
resources/lang/ko/errors.php
resources/lang/ko/settings.php
resources/lang/ko/validation.php
resources/lang/lt/activities.php [new file with mode: 0644]
resources/lang/lt/auth.php [new file with mode: 0644]
resources/lang/lt/common.php [new file with mode: 0644]
resources/lang/lt/components.php [new file with mode: 0644]
resources/lang/lt/entities.php [new file with mode: 0644]
resources/lang/lt/errors.php [new file with mode: 0644]
resources/lang/lt/pagination.php [new file with mode: 0644]
resources/lang/lt/passwords.php [new file with mode: 0644]
resources/lang/lt/settings.php [new file with mode: 0644]
resources/lang/lt/validation.php [new file with mode: 0644]
resources/lang/lv/activities.php
resources/lang/lv/auth.php
resources/lang/lv/common.php
resources/lang/lv/entities.php
resources/lang/lv/errors.php
resources/lang/lv/settings.php
resources/lang/lv/validation.php
resources/lang/nb/activities.php
resources/lang/nb/auth.php
resources/lang/nb/common.php
resources/lang/nb/entities.php
resources/lang/nb/errors.php
resources/lang/nb/settings.php
resources/lang/nb/validation.php
resources/lang/nl/activities.php
resources/lang/nl/auth.php
resources/lang/nl/common.php
resources/lang/nl/entities.php
resources/lang/nl/errors.php
resources/lang/nl/settings.php
resources/lang/nl/validation.php
resources/lang/pl/activities.php
resources/lang/pl/auth.php
resources/lang/pl/common.php
resources/lang/pl/entities.php
resources/lang/pl/errors.php
resources/lang/pl/settings.php
resources/lang/pl/validation.php
resources/lang/pt/activities.php
resources/lang/pt/auth.php
resources/lang/pt/common.php
resources/lang/pt/entities.php
resources/lang/pt/errors.php
resources/lang/pt/settings.php
resources/lang/pt/validation.php
resources/lang/pt_BR/activities.php
resources/lang/pt_BR/auth.php
resources/lang/pt_BR/common.php
resources/lang/pt_BR/entities.php
resources/lang/pt_BR/errors.php
resources/lang/pt_BR/settings.php
resources/lang/pt_BR/validation.php
resources/lang/ru/activities.php
resources/lang/ru/auth.php
resources/lang/ru/common.php
resources/lang/ru/entities.php
resources/lang/ru/errors.php
resources/lang/ru/settings.php
resources/lang/ru/validation.php
resources/lang/sk/activities.php
resources/lang/sk/auth.php
resources/lang/sk/common.php
resources/lang/sk/entities.php
resources/lang/sk/errors.php
resources/lang/sk/settings.php
resources/lang/sk/validation.php
resources/lang/sl/activities.php
resources/lang/sl/auth.php
resources/lang/sl/common.php
resources/lang/sl/entities.php
resources/lang/sl/errors.php
resources/lang/sl/settings.php
resources/lang/sl/validation.php
resources/lang/sv/activities.php
resources/lang/sv/auth.php
resources/lang/sv/common.php
resources/lang/sv/entities.php
resources/lang/sv/errors.php
resources/lang/sv/settings.php
resources/lang/sv/validation.php
resources/lang/th/auth.php
resources/lang/th/common.php
resources/lang/th/entities.php
resources/lang/th/settings.php
resources/lang/tr/activities.php
resources/lang/tr/auth.php
resources/lang/tr/common.php
resources/lang/tr/entities.php
resources/lang/tr/errors.php
resources/lang/tr/settings.php
resources/lang/tr/validation.php
resources/lang/uk/activities.php
resources/lang/uk/auth.php
resources/lang/uk/common.php
resources/lang/uk/entities.php
resources/lang/uk/errors.php
resources/lang/uk/settings.php
resources/lang/uk/validation.php
resources/lang/vi/activities.php
resources/lang/vi/auth.php
resources/lang/vi/common.php
resources/lang/vi/entities.php
resources/lang/vi/errors.php
resources/lang/vi/settings.php
resources/lang/vi/validation.php
resources/lang/zh_CN/activities.php
resources/lang/zh_CN/auth.php
resources/lang/zh_CN/common.php
resources/lang/zh_CN/entities.php
resources/lang/zh_CN/errors.php
resources/lang/zh_CN/settings.php
resources/lang/zh_CN/validation.php
resources/lang/zh_TW/activities.php
resources/lang/zh_TW/auth.php
resources/lang/zh_TW/common.php
resources/lang/zh_TW/entities.php
resources/lang/zh_TW/errors.php
resources/lang/zh_TW/settings.php
resources/lang/zh_TW/validation.php
resources/sass/_blocks.scss
resources/sass/_buttons.scss
resources/sass/_codemirror.scss
resources/sass/_components.scss
resources/sass/_forms.scss
resources/sass/_header.scss
resources/sass/_layout.scss
resources/sass/_lists.scss
resources/sass/_pages.scss
resources/sass/_tables.scss
resources/sass/_text.scss
resources/sass/export-styles.scss
resources/sass/print-styles.scss
resources/sass/styles.scss
resources/views/api-docs/index.blade.php
resources/views/api-docs/parts/endpoint.blade.php [new file with mode: 0644]
resources/views/api-docs/parts/getting-started.blade.php [new file with mode: 0644]
resources/views/attachments/list.blade.php
resources/views/attachments/manager-edit-form.blade.php
resources/views/attachments/manager-list.blade.php
resources/views/attachments/manager.blade.php
resources/views/auth/invite-set-password.blade.php
resources/views/auth/login.blade.php
resources/views/auth/parts/login-form-ldap.blade.php [moved from resources/views/auth/forms/login/ldap.blade.php with 100% similarity]
resources/views/auth/parts/login-form-oidc.blade.php [new file with mode: 0644]
resources/views/auth/parts/login-form-saml2.blade.php [moved from resources/views/auth/forms/login/saml2.blade.php with 79% similarity]
resources/views/auth/parts/login-form-standard.blade.php [moved from resources/views/auth/forms/login/standard.blade.php with 95% similarity]
resources/views/auth/passwords/email.blade.php
resources/views/auth/passwords/reset.blade.php
resources/views/auth/register-confirm.blade.php
resources/views/auth/register.blade.php
resources/views/auth/user-unconfirmed.blade.php
resources/views/books/_breadcrumbs.blade.php [deleted file]
resources/views/books/copy.blade.php [new file with mode: 0644]
resources/views/books/create.blade.php
resources/views/books/delete.blade.php
resources/views/books/edit.blade.php
resources/views/books/export.blade.php
resources/views/books/index.blade.php
resources/views/books/parts/form.blade.php [moved from resources/views/books/form.blade.php with 89% similarity]
resources/views/books/parts/list-item.blade.php [moved from resources/views/books/list-item.blade.php with 100% similarity]
resources/views/books/parts/list.blade.php [moved from resources/views/books/list.blade.php with 84% similarity]
resources/views/books/parts/sort-box.blade.php [moved from resources/views/books/sort-box.blade.php with 100% similarity]
resources/views/books/permissions.blade.php
resources/views/books/show.blade.php
resources/views/books/sort.blade.php
resources/views/chapters/copy.blade.php [new file with mode: 0644]
resources/views/chapters/create.blade.php
resources/views/chapters/delete.blade.php
resources/views/chapters/edit.blade.php
resources/views/chapters/export.blade.php
resources/views/chapters/move.blade.php
resources/views/chapters/parts/child-menu.blade.php [moved from resources/views/chapters/child-menu.blade.php with 81% similarity]
resources/views/chapters/parts/form.blade.php [moved from resources/views/chapters/form.blade.php with 86% similarity]
resources/views/chapters/parts/list-item.blade.php [moved from resources/views/chapters/list-item.blade.php with 92% similarity]
resources/views/chapters/permissions.blade.php
resources/views/chapters/show.blade.php
resources/views/comments/comment.blade.php
resources/views/comments/create.blade.php
resources/views/common/activity-item.blade.php [moved from resources/views/partials/activity-item.blade.php with 93% similarity]
resources/views/common/activity-list.blade.php [moved from resources/views/partials/activity-list.blade.php with 76% similarity]
resources/views/common/custom-head.blade.php [moved from resources/views/partials/custom-head.blade.php with 55% similarity]
resources/views/common/custom-styles.blade.php [moved from resources/views/partials/custom-styles.blade.php with 100% similarity]
resources/views/common/dark-mode-toggle.blade.php [moved from resources/views/partials/dark-mode-toggle.blade.php with 100% similarity]
resources/views/common/detailed-listing-paginated.blade.php [moved from resources/views/pages/detailed-listing.blade.php with 64% similarity]
resources/views/common/detailed-listing-with-more.blade.php [new file with mode: 0644]
resources/views/common/export-custom-head.blade.php [new file with mode: 0644]
resources/views/common/export-styles.blade.php [moved from resources/views/partials/export-styles.blade.php with 71% similarity]
resources/views/common/footer.blade.php
resources/views/common/header.blade.php
resources/views/common/home-sidebar.blade.php [deleted file]
resources/views/common/loading-icon.blade.php [moved from resources/views/partials/loading-icon.blade.php with 100% similarity]
resources/views/common/notifications.blade.php [moved from resources/views/partials/notifications.blade.php with 100% similarity]
resources/views/common/skip-to-content.blade.php [new file with mode: 0644]
resources/views/components/tag-list.blade.php [deleted file]
resources/views/components/tag-manager.blade.php [deleted file]
resources/views/entities/book-tree.blade.php [moved from resources/views/partials/book-tree.blade.php with 76% similarity]
resources/views/entities/breadcrumb-listing.blade.php [moved from resources/views/partials/breadcrumb-listing.blade.php with 95% similarity]
resources/views/entities/breadcrumbs.blade.php [moved from resources/views/partials/breadcrumbs.blade.php with 96% similarity]
resources/views/entities/copy-considerations.blade.php [new file with mode: 0644]
resources/views/entities/export-menu.blade.php [moved from resources/views/partials/entity-export-menu.blade.php with 52% similarity]
resources/views/entities/export-meta.blade.php [moved from resources/views/partials/entity-export-meta.blade.php with 58% similarity]
resources/views/entities/favourite-action.blade.php [new file with mode: 0644]
resources/views/entities/grid-item.blade.php [moved from resources/views/partials/entity-grid-item.blade.php with 100% similarity]
resources/views/entities/list-basic.blade.php [moved from resources/views/partials/entity-list-basic.blade.php with 76% similarity]
resources/views/entities/list-item-basic.blade.php [moved from resources/views/partials/entity-list-item-basic.blade.php with 90% similarity]
resources/views/entities/list-item.blade.php [new file with mode: 0644]
resources/views/entities/list.blade.php [moved from resources/views/partials/entity-list.blade.php with 65% similarity]
resources/views/entities/meta.blade.php [moved from resources/views/partials/entity-meta.blade.php with 100% similarity]
resources/views/entities/search-form.blade.php [moved from resources/views/partials/entity-search-form.blade.php with 100% similarity]
resources/views/entities/search-results.blade.php [moved from resources/views/partials/entity-search-results.blade.php with 91% similarity]
resources/views/entities/selector-popup.blade.php [moved from resources/views/components/entity-selector-popup.blade.php with 88% similarity]
resources/views/entities/selector.blade.php [moved from resources/views/components/entity-selector.blade.php with 77% similarity]
resources/views/entities/sibling-navigation.blade.php [new file with mode: 0644]
resources/views/entities/sort.blade.php [moved from resources/views/partials/sort.blade.php with 100% similarity]
resources/views/entities/tag-list.blade.php [new file with mode: 0644]
resources/views/entities/tag-manager-list.blade.php [moved from resources/views/components/tag-manager-list.blade.php with 100% similarity]
resources/views/entities/tag-manager.blade.php [new file with mode: 0644]
resources/views/entities/tag.blade.php [new file with mode: 0644]
resources/views/entities/view-toggle.blade.php [moved from resources/views/partials/view-toggle.blade.php with 100% similarity]
resources/views/errors/404.blade.php
resources/views/errors/500.blade.php
resources/views/errors/503.blade.php
resources/views/errors/debug.blade.php [new file with mode: 0644]
resources/views/errors/parts/not-found-text.blade.php [new file with mode: 0644]
resources/views/form/checkbox.blade.php
resources/views/form/custom-checkbox.blade.php [moved from resources/views/components/custom-checkbox.blade.php with 100% similarity]
resources/views/form/dropzone.blade.php [moved from resources/views/components/dropzone.blade.php with 88% similarity]
resources/views/form/entity-permissions.blade.php
resources/views/form/errors.blade.php [new file with mode: 0644]
resources/views/form/image-picker.blade.php [moved from resources/views/components/image-picker.blade.php with 100% similarity]
resources/views/form/number.blade.php [new file with mode: 0644]
resources/views/form/request-query-inputs.blade.php [new file with mode: 0644]
resources/views/form/restriction-checkbox.blade.php
resources/views/form/role-checkboxes.blade.php
resources/views/form/toggle-switch.blade.php [moved from resources/views/components/toggle-switch.blade.php with 100% similarity]
resources/views/form/user-select-list.blade.php [moved from resources/views/components/user-select-list.blade.php with 100% similarity]
resources/views/form/user-select.blade.php [moved from resources/views/components/user-select.blade.php with 96% similarity]
resources/views/home/books.blade.php [moved from resources/views/common/home-book.blade.php with 53% similarity]
resources/views/home/default.blade.php [moved from resources/views/common/home.blade.php with 58% similarity]
resources/views/home/parts/expand-toggle.blade.php [moved from resources/views/components/expand-toggle.blade.php with 91% similarity]
resources/views/home/parts/sidebar.blade.php [new file with mode: 0644]
resources/views/home/shelves.blade.php [moved from resources/views/common/home-shelves.blade.php with 54% similarity]
resources/views/home/specific-page.blade.php [moved from resources/views/common/home-custom.blade.php with 50% similarity]
resources/views/layouts/base.blade.php [moved from resources/views/base.blade.php with 74% similarity]
resources/views/layouts/export.blade.php [moved from resources/views/export-layout.blade.php with 64% similarity]
resources/views/layouts/simple.blade.php [moved from resources/views/simple-layout.blade.php with 68% similarity]
resources/views/layouts/tri.blade.php [moved from resources/views/tri-layout.blade.php with 94% similarity]
resources/views/mfa/backup-codes-generate.blade.php [new file with mode: 0644]
resources/views/mfa/parts/setup-method-row.blade.php [new file with mode: 0644]
resources/views/mfa/parts/verify-backup_codes.blade.php [new file with mode: 0644]
resources/views/mfa/parts/verify-totp.blade.php [new file with mode: 0644]
resources/views/mfa/setup.blade.php [new file with mode: 0644]
resources/views/mfa/totp-generate.blade.php [new file with mode: 0644]
resources/views/mfa/verify.blade.php [new file with mode: 0644]
resources/views/misc/robots.blade.php [moved from resources/views/common/robots.blade.php with 100% similarity]
resources/views/pages/copy.blade.php
resources/views/pages/delete.blade.php
resources/views/pages/edit.blade.php
resources/views/pages/export.blade.php
resources/views/pages/guest-create.blade.php
resources/views/pages/move.blade.php
resources/views/pages/parts/code-editor.blade.php [moved from resources/views/components/code-editor.blade.php with 97% similarity]
resources/views/pages/parts/editor-toolbox.blade.php [moved from resources/views/pages/editor-toolbox.blade.php with 86% similarity]
resources/views/pages/parts/form.blade.php [moved from resources/views/pages/form.blade.php with 93% similarity]
resources/views/pages/parts/image-manager-form.blade.php [moved from resources/views/components/image-manager-form.blade.php with 92% similarity]
resources/views/pages/parts/image-manager-list.blade.php [moved from resources/views/components/image-manager-list.blade.php with 100% similarity]
resources/views/pages/parts/image-manager.blade.php [moved from resources/views/components/image-manager.blade.php with 98% similarity]
resources/views/pages/parts/list-item.blade.php [moved from resources/views/pages/list-item.blade.php with 60% similarity]
resources/views/pages/parts/markdown-editor.blade.php [moved from resources/views/pages/markdown-editor.blade.php with 95% similarity]
resources/views/pages/parts/page-display.blade.php [moved from resources/views/pages/page-display.blade.php with 100% similarity]
resources/views/pages/parts/pointer.blade.php [moved from resources/views/pages/pointer.blade.php with 100% similarity]
resources/views/pages/parts/template-manager-list.blade.php [moved from resources/views/pages/template-manager-list.blade.php with 100% similarity]
resources/views/pages/parts/template-manager.blade.php [moved from resources/views/pages/template-manager.blade.php with 86% similarity]
resources/views/pages/parts/wysiwyg-editor.blade.php [moved from resources/views/pages/wysiwyg-editor.blade.php with 85% similarity]
resources/views/pages/permissions.blade.php
resources/views/pages/revision.blade.php
resources/views/pages/revisions.blade.php
resources/views/pages/show.blade.php
resources/views/pages/sidebar-tree-list.blade.php [deleted file]
resources/views/partials/entity-list-item.blade.php [deleted file]
resources/views/partials/export-custom-head.blade.php [deleted file]
resources/views/readme.md [new file with mode: 0644]
resources/views/search/all.blade.php
resources/views/search/parts/boolean-filter.blade.php [moved from resources/views/search/form/boolean-filter.blade.php with 100% similarity]
resources/views/search/parts/date-filter.blade.php [moved from resources/views/search/form/date-filter.blade.php with 100% similarity]
resources/views/search/parts/entity-ajax-list.blade.php [moved from resources/views/search/entity-ajax-list.blade.php with 77% similarity]
resources/views/search/parts/term-list.blade.php [moved from resources/views/search/form/term-list.blade.php with 100% similarity]
resources/views/search/parts/type-filter.blade.php [moved from resources/views/search/form/type-filter.blade.php with 100% similarity]
resources/views/settings/audit.blade.php
resources/views/settings/index.blade.php
resources/views/settings/maintenance.blade.php
resources/views/settings/navbar-with-version.blade.php [deleted file]
resources/views/settings/parts/footer-links.blade.php [moved from resources/views/settings/footer-links.blade.php with 100% similarity]
resources/views/settings/parts/navbar-with-version.blade.php [new file with mode: 0644]
resources/views/settings/parts/navbar.blade.php [moved from resources/views/settings/navbar.blade.php with 82% similarity]
resources/views/settings/parts/page-picker.blade.php [moved from resources/views/components/page-picker.blade.php with 78% similarity]
resources/views/settings/parts/setting-entity-color-picker.blade.php [moved from resources/views/components/setting-entity-color-picker.blade.php with 100% similarity]
resources/views/settings/parts/table-user.blade.php [moved from resources/views/partials/table-user.blade.php with 100% similarity]
resources/views/settings/recycle-bin/deletable-entity-list.blade.php [deleted file]
resources/views/settings/recycle-bin/destroy.blade.php
resources/views/settings/recycle-bin/index.blade.php
resources/views/settings/recycle-bin/parts/deletable-entity-list.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/parts/entity-display-item.blade.php [moved from resources/views/partials/entity-display-item.blade.php with 100% similarity]
resources/views/settings/recycle-bin/restore.blade.php
resources/views/settings/roles/create.blade.php
resources/views/settings/roles/delete.blade.php
resources/views/settings/roles/edit.blade.php
resources/views/settings/roles/form.blade.php [deleted file]
resources/views/settings/roles/index.blade.php
resources/views/settings/roles/parts/checkbox.blade.php [moved from resources/views/settings/roles/checkbox.blade.php with 85% similarity]
resources/views/settings/roles/parts/form.blade.php [new file with mode: 0644]
resources/views/settings/webhooks/create.blade.php [new file with mode: 0644]
resources/views/settings/webhooks/delete.blade.php [new file with mode: 0644]
resources/views/settings/webhooks/edit.blade.php [new file with mode: 0644]
resources/views/settings/webhooks/index.blade.php [new file with mode: 0644]
resources/views/settings/webhooks/parts/form.blade.php [new file with mode: 0644]
resources/views/settings/webhooks/parts/format-example.blade.php [new file with mode: 0644]
resources/views/shelves/_breadcrumbs.blade.php [deleted file]
resources/views/shelves/create.blade.php
resources/views/shelves/delete.blade.php
resources/views/shelves/edit.blade.php
resources/views/shelves/index.blade.php
resources/views/shelves/parts/form.blade.php [moved from resources/views/shelves/form.blade.php with 93% similarity]
resources/views/shelves/parts/list-item.blade.php [moved from resources/views/shelves/list-item.blade.php with 100% similarity]
resources/views/shelves/parts/list.blade.php [moved from resources/views/shelves/list.blade.php with 83% similarity]
resources/views/shelves/permissions.blade.php
resources/views/shelves/show.blade.php
resources/views/tags/index.blade.php [new file with mode: 0644]
resources/views/tags/parts/table-row.blade.php [new file with mode: 0644]
resources/views/users/api-tokens/create.blade.php
resources/views/users/api-tokens/delete.blade.php
resources/views/users/api-tokens/edit.blade.php
resources/views/users/api-tokens/parts/form.blade.php [moved from resources/views/users/api-tokens/form.blade.php with 100% similarity]
resources/views/users/api-tokens/parts/list.blade.php [moved from resources/views/users/api-tokens/list.blade.php with 100% similarity]
resources/views/users/create.blade.php
resources/views/users/delete.blade.php
resources/views/users/edit.blade.php
resources/views/users/index.blade.php
resources/views/users/parts/form.blade.php [moved from resources/views/users/form.blade.php with 95% similarity]
resources/views/users/parts/language-option-row.blade.php [new file with mode: 0644]
resources/views/users/profile.blade.php
routes/api.php
routes/console.php [new file with mode: 0644]
routes/web.php
server.php
storage/clockwork/.gitignore [new file with mode: 0644]
storage/framework/.gitignore
tests/Actions/AuditLogTest.php [moved from tests/AuditLogTest.php with 54% similarity]
tests/Actions/WebhookCallTest.php [new file with mode: 0644]
tests/Actions/WebhookManagementTest.php [new file with mode: 0644]
tests/ActivityTrackingTest.php [deleted file]
tests/Api/ApiAuthTest.php
tests/Api/ApiConfigTest.php
tests/Api/ApiDocsTest.php
tests/Api/ApiListingTest.php
tests/Api/AttachmentsApiTest.php [new file with mode: 0644]
tests/Api/BooksApiTest.php
tests/Api/ChaptersApiTest.php
tests/Api/PagesApiTest.php
tests/Api/SearchApiTest.php [new file with mode: 0644]
tests/Api/ShelvesApiTest.php
tests/Api/TestsApi.php
tests/Auth/AuthTest.php
tests/Auth/LdapTest.php
tests/Auth/MfaConfigurationTest.php [new file with mode: 0644]
tests/Auth/MfaVerificationTest.php [new file with mode: 0644]
tests/Auth/OidcTest.php [new file with mode: 0644]
tests/Auth/Saml2Test.php
tests/Auth/SocialAuthTest.php
tests/Auth/UserInviteTest.php
tests/BrowserKitTest.php [deleted file]
tests/Commands/AddAdminCommandTest.php [deleted file]
tests/Commands/ClearActivityCommandTest.php
tests/Commands/ClearRevisionsCommandTest.php
tests/Commands/ClearViewsCommandTest.php
tests/Commands/CopyShelfPermissionsCommandTest.php
tests/Commands/CreateAdminCommandTest.php [new file with mode: 0644]
tests/Commands/RegenerateCommentContentCommandTest.php
tests/Commands/RegeneratePermissionsCommandTest.php
tests/Commands/ResetMfaCommandTest.php [new file with mode: 0644]
tests/Commands/UpdateUrlCommandTest.php
tests/CreatesApplication.php
tests/DebugViewTest.php [new file with mode: 0644]
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php
tests/Entity/ChapterTest.php
tests/Entity/CommentSettingTest.php
tests/Entity/CommentTest.php
tests/Entity/EntityAccessTest.php [new file with mode: 0644]
tests/Entity/EntitySearchTest.php
tests/Entity/EntityTest.php [deleted file]
tests/Entity/ExportTest.php
tests/Entity/MarkdownTest.php [deleted file]
tests/Entity/PageContentTest.php
tests/Entity/PageDraftTest.php
tests/Entity/PageEditorTest.php [new file with mode: 0644]
tests/Entity/PageRevisionTest.php
tests/Entity/PageTemplateTest.php
tests/Entity/PageTest.php
tests/Entity/SearchOptionsTest.php
tests/Entity/SortTest.php
tests/Entity/TagTest.php
tests/ErrorTest.php
tests/FavouriteTest.php [new file with mode: 0644]
tests/Helpers/OidcJwtHelper.php [new file with mode: 0644]
tests/HomepageTest.php
tests/LanguageTest.php
tests/OpenGraphTest.php [new file with mode: 0644]
tests/Permissions/EntityOwnerChangeTest.php
tests/Permissions/EntityPermissionsTest.php
tests/Permissions/ExportPermissionsTest.php
tests/Permissions/RolesTest.php
tests/PublicActionTest.php
tests/RecycleBinTest.php
tests/SecurityHeaderTest.php
tests/Settings/CustomHeadContentTest.php [new file with mode: 0644]
tests/Settings/FooterLinksTest.php [moved from tests/FooterLinksTest.php with 86% similarity]
tests/SharedTestHelpers.php
tests/StatusTest.php
tests/TestCase.php
tests/TestEmailTest.php
tests/TestResponse.php
tests/ThemeTest.php
tests/Unit/ConfigTest.php
tests/Unit/FrameworkAssumptionTest.php [new file with mode: 0644]
tests/Unit/OidcIdTokenTest.php [new file with mode: 0644]
tests/Unit/UrlTest.php
tests/Uploads/AttachmentTest.php
tests/Uploads/AvatarTest.php
tests/Uploads/DrawioTest.php
tests/Uploads/ImageTest.php
tests/Uploads/UsesImages.php
tests/User/UserApiTokenTest.php
tests/User/UserManagementTest.php
tests/User/UserPreferencesTest.php
tests/User/UserProfileTest.php
tests/User/UserSearchTest.php [new file with mode: 0644]
tests/test-data/animated.png [new file with mode: 0644]
version

index 05383f04abcce2f08d732f2b08719cb5b3775a76..a0a1b72e6836bcab495a34c0f8633ea295edb644 100644 (file)
@@ -41,4 +41,4 @@ MAIL_HOST=localhost
 MAIL_PORT=1025
 MAIL_USERNAME=null
 MAIL_PASSWORD=null
-MAIL_ENCRYPTION=null
\ No newline at end of file
+MAIL_ENCRYPTION=null
index d243f2c1fcb9d13b52ba9dc9e55a27b07e663f79..9d24fceeb1ef4f086f65d65ed5e9ce704febd54f 100644 (file)
@@ -42,6 +42,14 @@ APP_TIMEZONE=UTC
 # overrides can be made. Defaults to disabled.
 APP_THEME=false
 
+# Trusted Proxies
+# Used to indicate trust of systems that proxy to the application so
+# certain header values (Such as "X-Forwarded-For") can be used from the
+# incoming proxy request to provide origin detail.
+# Set to an IP address, or multiple comma seperated IP addresses.
+# Can alternatively be set to "*" to trust all proxy addresses.
+APP_PROXIES=null
+
 # Database details
 # Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
 DB_HOST=localhost
@@ -92,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100
 REDIS_SERVERS=127.0.0.1:6379:0
 
 # Queue driver to use
-# Queue not really currently used but may be configurable in the future.
-# Would advise not to change this for now.
+# Can be 'sync', 'database' or 'redis'
 QUEUE_CONNECTION=sync
 
 # Storage system to use
@@ -126,7 +133,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
 STORAGE_URL=false
 
 # Authentication method to use
-# Can be 'standard', 'ldap' or 'saml2'
+# Can be 'standard', 'ldap', 'saml2' or 'oidc'
 AUTH_METHOD=standard
 
 # Social authentication configuration
@@ -200,6 +207,7 @@ LDAP_TLS_INSECURE=false
 LDAP_ID_ATTRIBUTE=uid
 LDAP_EMAIL_ATTRIBUTE=mail
 LDAP_DISPLAY_NAME_ATTRIBUTE=cn
+LDAP_THUMBNAIL_ATTRIBUTE=null
 LDAP_FOLLOW_REFERRALS=true
 LDAP_DUMP_USER_DETAILS=false
 
@@ -222,6 +230,9 @@ SAML2_IDP_x509=null
 SAML2_ONELOGIN_OVERRIDES=null
 SAML2_DUMP_USER_DETAILS=false
 SAML2_AUTOLOAD_METADATA=false
+SAML2_IDP_AUTHNCONTEXT=true
+SAML2_SP_x509=null
+SAML2_SP_x509_KEY=null
 
 # SAML group sync configuration
 # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
@@ -229,6 +240,19 @@ SAML2_USER_TO_GROUPS=false
 SAML2_GROUP_ATTRIBUTE=group
 SAML2_REMOVE_FROM_GROUPS=false
 
+# OpenID Connect authentication configuration
+# Refer to https://www.bookstackapp.com/docs/admin/oidc-auth/
+OIDC_NAME=SSO
+OIDC_DISPLAY_NAME_CLAIMS=name
+OIDC_CLIENT_ID=null
+OIDC_CLIENT_SECRET=null
+OIDC_ISSUER=null
+OIDC_ISSUER_DISCOVER=false
+OIDC_PUBLIC_KEY=null
+OIDC_AUTH_ENDPOINT=null
+OIDC_TOKEN_ENDPOINT=null
+OIDC_DUMP_USER_DETAILS=false
+
 # Disable default third-party services such as Gravatar and Draw.IO
 # Service-specific options will override this option
 DISABLE_EXTERNAL_SERVICES=false
@@ -269,6 +293,15 @@ REVISION_LIMIT=50
 # Set to -1 for unlimited recycle bin lifetime.
 RECYCLE_BIN_LIFETIME=30
 
+# File Upload Limit
+# Maximum file size, in megabytes, that can be uploaded to the system.
+FILE_UPLOAD_SIZE_LIMIT=50
+
+# Export Page Size
+# Primarily used to determine page size of PDF exports.
+# Can be 'a4' or 'letter'.
+EXPORT_PAGE_SIZE=a4
+
 # Allow <script> tags in page content
 # Note, if set to 'true' the page editor may still escape scripts.
 ALLOW_CONTENT_SCRIPTS=false
@@ -279,6 +312,12 @@ ALLOW_CONTENT_SCRIPTS=false
 # Contents of the robots.txt file can be overridden, making this option obsolete.
 ALLOW_ROBOTS=null
 
+# Allow server-side fetches to be performed to potentially unknown
+# and user-provided locations. Primarily used in exports when loading
+# in externally referenced assets.
+# Can be 'true' or 'false'.
+ALLOW_UNTRUSTED_SERVER_FETCHING=false
+
 # A list of hosts that BookStack can be iframed within.
 # Space separated if multiple. BookStack host domain is auto-inferred.
 # For Example: ALLOWED_IFRAME_HOSTS="https://example.com https://a.example.com"
diff --git a/.github/ISSUE_TEMPLATE/api_request.md b/.github/ISSUE_TEMPLATE/api_request.md
deleted file mode 100644 (file)
index dc050ef..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
----
-name: New API Endpoint or Feature
-about: Request a new endpoint or API feature be added
-labels: ":nut_and_bolt: API Request"
----
-
-#### API Endpoint or Feature
-
-Clearly describe what you'd like to have added to the API. 
-
-#### Use-Case
-
-Explain the use-case that you're working-on that requires the above request.
-
-#### Additional Context
-
-If required, add any other context about the feature request here.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/api_request.yml b/.github/ISSUE_TEMPLATE/api_request.yml
new file mode 100644 (file)
index 0000000..81e11e2
--- /dev/null
@@ -0,0 +1,26 @@
+name: New API Endpoint or API Ability
+description: Request a new endpoint or API feature be added
+title: "[API Request]: "
+labels: [":nut_and_bolt: API Request"]
+body:
+  - type: textarea
+    id: feature
+    attributes:
+      label: API Endpoint or Feature
+      description: Clearly describe what you'd like to have added to the API.
+    validations:
+      required: true
+  - type: textarea
+    id: usecase
+    attributes:
+      label: Use-Case
+      description: Explain the use-case that you're working-on that requires the above request.
+    validations:
+      required: true
+  - type: textarea
+    id: context
+    attributes:
+      label: Additional context
+      description: Add any other context about the feature request here.
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644 (file)
index c4444f2..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
----
-name: Bug Report
-about: Create a report to help us improve
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**Steps To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Your Configuration (please complete the following information):**
- - Exact BookStack Version (Found in settings):
- - PHP Version:
- - Hosting Method (Nginx/Apache/Docker): 
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644 (file)
index 0000000..35aa481
--- /dev/null
@@ -0,0 +1,62 @@
+name: Bug Report
+description: Create a report to help us improve or fix things
+title: "[Bug Report]: "
+labels: [":bug: Bug"]
+body:
+  - type: textarea
+    id: description
+    attributes:
+      label: Describe the Bug
+      description: Provide a clear and concise description of what the bug is.
+    validations:
+      required: true
+  - type: textarea
+    id: reproduction
+    attributes:
+      label: Steps to Reproduce
+      description: Detail the steps that would replicate this issue
+      placeholder: |
+        1. Go to '...'
+        2. Click on '....'
+        3. Scroll down to '....'
+        4. See error
+    validations:
+      required: true
+  - type: textarea
+    id: expected
+    attributes:
+      label: Expected Behaviour
+      description: Provide clear and concise description of what you expected to happen.
+    validations:
+      required: true
+  - type: textarea
+    id: context
+    attributes:
+      label: Screenshots or Additional Context
+      description: Provide any additional context and screenshots here to help us solve this issue
+    validations:
+      required: false
+  - type: input
+    id: bsversion
+    attributes:
+      label: Exact BookStack Version
+      description: This can be found in the settings view of BookStack. Please provide an exact version.
+      placeholder: (eg. v21.08.5)
+    validations:
+      required: true
+  - type: input
+    id: phpversion
+    attributes:
+      label: PHP Version
+      description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
+      placeholder: (eg. 7.4)
+    validations:
+      required: false
+  - type: textarea
+    id: hosting
+    attributes:
+      label: Hosting Environment
+      description: Describe your hosting environment as much as possible including any proxies used (If applicable).
+      placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
+    validations:
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644 (file)
index 781cca5..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
----
-name: Feature Request
-about: Suggest an idea for this project
-
----
-
-**Describe the feature you'd like**
-A clear description of the feature you'd like implemented in BookStack.
-
-**Describe the benefits this feature would bring to BookStack users**
-Explain the measurable benefits this feature would achieve.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644 (file)
index 0000000..a945c34
--- /dev/null
@@ -0,0 +1,26 @@
+name: Feature Request
+description: Request a new language to be added to CrowdIn for you to translate
+title: "[Feature Request]: "
+labels: [":hammer: Feature Request"]
+body:
+  - type: textarea
+    id: description
+    attributes:
+      label: Describe the feature you'd like
+      description: Provide a clear description of the feature you'd like implemented in BookStack
+    validations:
+      required: true
+  - type: textarea
+    id: benefits
+    attributes:
+      label: Describe the benefits this feature would bring to BookStack users
+      description: Explain the measurable benefits this feature would achieve for existing BookStack users
+    validations:
+      required: true
+  - type: textarea
+    id: context
+    attributes:
+      label: Additional context
+      description: Add any other context or screenshots about the feature request here.
+    validations:
+      required: false
diff --git a/.github/ISSUE_TEMPLATE/language_request.md b/.github/ISSUE_TEMPLATE/language_request.md
deleted file mode 100644 (file)
index 249ef78..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
----
-name: Language Request
-about: Request a new language to be added to Crowdin for you to translate
-
----
-
-### Language To Add
-
-_Specify here the language you want to add._
-
-----
-
-_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._   
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/language_request.yml b/.github/ISSUE_TEMPLATE/language_request.yml
new file mode 100644 (file)
index 0000000..b94bb88
--- /dev/null
@@ -0,0 +1,32 @@
+name: Language Request
+description: Request a new language to be added to CrowdIn for you to translate
+title: "[Language Request]: "
+labels: [":earth_africa: Translations"]
+assignees:
+  - ssddanbrown
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for offering to help start a new translation for BookStack!
+  - type: input
+    id: language
+    attributes:
+      label: Language to Add
+      description: What language (and region if applicable) are you offering to help add to BookStack?
+    validations:
+      required: true
+  - type: checkboxes
+    id: confirm
+    attributes:
+      label: Confirmation of Intent
+      description: |
+        This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
+        Please don't use this template to request a new language that you are not prepared to provide translations for.
+      options:
+        - label: I confirm I'm offering to help translate for this new language via CrowdIn.
+          required: true
+  - type: markdown
+    attributes:
+      value: |
+        *__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__*
diff --git a/.github/ISSUE_TEMPLATE/support_request.yml b/.github/ISSUE_TEMPLATE/support_request.yml
new file mode 100644 (file)
index 0000000..bd52b12
--- /dev/null
@@ -0,0 +1,63 @@
+name: Support Request
+description: Request support for a specific problem you have not been able to solve yourself
+title: "[Support Request]: "
+labels: [":dog2: Support"]
+body:
+  - type: checkboxes
+    id: useddocs
+    attributes:
+      label: Attempted Debugging
+      description: |
+        I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more
+        detail for the issue.
+      options:
+        - label: I have read the debugging page
+          required: true
+  - type: checkboxes
+    id: searchissue
+    attributes:
+      label: Searched GitHub Issues
+      description: |
+        I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
+      options:
+        - label: I have searched GitHub for the issue.
+          required: true
+  - type: textarea
+    id: scenario
+    attributes:
+      label: Describe the Scenario
+      description: Detail the problem that you're having or what you need support with.
+    validations:
+      required: true
+  - type: input
+    id: bsversion
+    attributes:
+      label: Exact BookStack Version
+      description: This can be found in the settings view of BookStack. Please provide an exact version.
+      placeholder: (eg. v21.08.5)
+    validations:
+      required: true
+  - type: textarea
+    id: logs
+    attributes:
+      label: Log Content
+      description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
+      placeholder: Be sure to remove any confidential details in your logs
+    validations:
+      required: false
+  - type: input
+    id: phpversion
+    attributes:
+      label: PHP Version
+      description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
+      placeholder: (eg. 7.4)
+    validations:
+      required: false
+  - type: textarea
+    id: hosting
+    attributes:
+      label: Hosting Environment
+      description: Describe your hosting environment as much as possible including any proxies used (If applicable).
+      placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
+    validations:
+      required: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644 (file)
index 0000000..c2201a6
--- /dev/null
@@ -0,0 +1,32 @@
+# Security Policy
+
+## Supported Versions
+
+Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported.
+We generally don't support older versions of BookStack due to maintenance effort and
+since we aim to provide a fairly stable upgrade path for new versions.
+
+## Security Notifications
+
+If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
+
+## Reporting a Vulnerability
+
+If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
+feel free to raise it via a standard GitHub bug report issue.
+
+If the issue could have a security impact to BookStack instances, please use one of the below 
+methods to report the vulnerability:
+
+- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown). 
+  - You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
+  - Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
+- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
+  - Bounties may be available to you through this platform.
+  - Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
+
+Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
+can often take a little time due to the amount of preparation required, to ensure the vulnerability has
+been covered, and to create the content required to adequately notify the user-base.
+
+Thank you for keeping BookStack instances safe!
\ No newline at end of file
index 663dcf6da1ca5f45df8f60d4315d3ca1429edf59..9cf93c9cb5398b0c9b567e77bfe6eff2832c66d1 100644 (file)
@@ -54,6 +54,7 @@ Name :: Languages
 @benediktvolke :: German
 @Baptistou :: French
 @arcoai :: Spanish
+@Jokuna :: Korean
 cipi1965 :: Italian
 Mykola Ronik (Mantikor) :: Ukrainian
 furkanoyk :: Turkish
@@ -125,7 +126,7 @@ Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
 tatsuya.info :: Japanese
 fadiapp :: Arabic
 Jakub Bouček (jakubboucek) :: Czech
-Marco (cdrfun) :: German
+Marco (cdrfun) :: German; German Informal
 10935336 :: Chinese Simplified
 孟繁阳 (FanyangMeng) :: Chinese Simplified
 Andrej Močan (andrejm) :: Slovenian
@@ -157,7 +158,60 @@ HenrijsS :: Latvian
 Pascal R-B (pborgner) :: German
 Boris (Ginfred) :: Russian
 Jonas Anker Rasmussen (jonasanker) :: Danish
-Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
+Gerwin de Keijzer (gdekeijzer) :: Dutch; German; German Informal
 kometchtech :: Japanese
 Auri (Atalonica) :: Catalan
 Francesco Franchina (ffranchina) :: Italian
+Aimrane Kds (aimrane.kds) :: Arabic
+whenwesober :: Indonesian
+Rem (remkovdhoef) :: Dutch
+syn7ax69 :: Bulgarian; Turkish
+Blaade :: French
+Behzad HosseinPoor (behzad.hp) :: Persian
+Ole Aldric (Swoy) :: Norwegian Bokmal
+fharis arabia (raednahdi) :: Arabic
+Alexander Predl (Harveyhase68) :: German
+Rem (Rem9000) :: Dutch
+Michał Stelmach (stelmach-web) :: Polish
+arniom :: French
+REMOVED_USER :: Turkish
+林祖年 (contagion) :: Chinese Traditional
+Siamak Guodarzi (siamakgoudarzi88) :: Persian
+Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
+Nathanaël (nathanaelhoun) :: French
+A Ibnu Hibban (abd.ibnuhibban) :: Indonesian
+Frost-ZX :: Chinese Simplified
+Kuzma Simonov (ovmach) :: Russian
+Vojtěch Krystek (acantophis) :: Czech
+Michał Lipok (mLipok) :: Polish
+Nicolas Pawlak (Mikolajek) :: French; Polish; German
+Thomas Hansen (thomasdk81) :: Danish
+Hl2run :: Slovak
+Ngo Tri Hoai (trihoai) :: Vietnamese
+Atalonica :: Catalan
+慕容潭谈 (591442386) :: Chinese Simplified
+Radim Pesek (ramess18) :: Czech
+anastasiia.motylko :: Ukrainian
+Indrek Haav (IndrekHaav) :: Estonian
+na3shkw :: Japanese
+Giancarlo Di Massa (digitall-it) :: Italian
+M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
+sulfo :: Danish
+Raukze :: German
+zygimantus :: Lithuanian
+marinkaberg :: Russian
+Vitaliy (gviabcua) :: Ukrainian
+mannycarreiro :: Portuguese
+Thiago Rafael Pereira de Carvalho (thiago.rafael) :: Portuguese, Brazilian
+Ken Roger Bolgnes (kenbo124) :: Norwegian Bokmal
+Nguyen Hung Phuong (hnwolf) :: Vietnamese
+Umut ERGENE (umutergene67) :: Turkish
+Tomáš Batelka (Vofy) :: Czech
+Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
+Zarik (3apuk) :: Russian
+Ali Shaatani (a.shaatani) :: Arabic
+ChacMaster :: Portuguese, Brazilian
+Saeed (saeed205) :: Persian
+Julesdevops :: French
+peter cerny (posli.to.semka) :: Slovak
+Pavel Karlin (pavelkarlin) :: Russian
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644 (file)
index 0000000..c3a24fd
--- /dev/null
@@ -0,0 +1,41 @@
+name: phpstan
+
+on:
+  push:
+    branches-ignore:
+      - l10n_development
+  pull_request:
+    branches-ignore:
+      - l10n_development
+
+jobs:
+  build:
+    runs-on: ubuntu-20.04
+    strategy:
+      matrix:
+        php: ['7.3']
+    steps:
+    - uses: actions/checkout@v1
+
+    - name: Setup PHP
+      uses: shivammathur/setup-php@v2
+      with:
+        php-version: ${{ matrix.php }}
+        extensions: gd, mbstring, json, curl, xml, mysql, ldap
+
+    - name: Get Composer Cache Directory
+      id: composer-cache
+      run: |
+        echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+    - name: Cache composer packages
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.composer-cache.outputs.dir }}
+        key: ${{ runner.os }}-composer-${{ matrix.php }}
+
+    - name: Install composer dependencies
+      run: composer install --prefer-dist --no-interaction --ansi
+
+    - name: Run PHPStan
+      run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G
index b0ef05b8fcf43c0c001ef5615ce360ba2d0c45e3..ea72811983c3a8166b9781ebfb02f7016837fee3 100644 (file)
@@ -2,27 +2,23 @@ name: phpunit
 
 on:
   push:
-    branches:
-      - master
-      - release
-      - gh_actions_update
+    branches-ignore:
+      - l10n_development
   pull_request:
-    branches:
-      - '*'
-      - '*/*'
-      - '!l10n_master'
+    branches-ignore:
+      - l10n_development
 
 jobs:
   build:
     runs-on: ubuntu-20.04
     strategy:
       matrix:
-        php: ['7.3', '7.4', '8.0']
+        php: ['7.3', '7.4', '8.0', '8.1']
     steps:
     - uses: actions/checkout@v1
 
     - name: Setup PHP
-      uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
+      uses: shivammathur/setup-php@v2
       with:
         php-version: ${{ matrix.php }}
         extensions: gd, mbstring, json, curl, xml, mysql, ldap
@@ -40,7 +36,7 @@ jobs:
 
     - name: Start Database
       run: |
-        sudo /etc/init.d/mysql start
+        sudo systemctl start mysql
 
     - name: Setup Database
       run: |
@@ -49,7 +45,7 @@ jobs:
         mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
         mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
 
-    - name: Install composer dependencies & Test
+    - name: Install composer dependencies
       run: composer install --prefer-dist --no-interaction --ansi
 
     - name: Migrate and seed the database
index 34aaf9c3fc9b1f2c1cb2b09a2dc08aeb792f697b..7195f75cee63640c39d6269ca67eb54171e77992 100644 (file)
@@ -2,27 +2,23 @@ name: test-migrations
 
 on:
   push:
-    branches:
-      - master
-      - release
-      - gh_actions_update
+    branches-ignore:
+      - l10n_development
   pull_request:
-    branches:
-      - '*'
-      - '*/*'
-      - '!l10n_master'
+    branches-ignore:
+      - l10n_development
 
 jobs:
   build:
     runs-on: ubuntu-20.04
     strategy:
       matrix:
-        php: ['7.3', '7.4', '8.0']
+        php: ['7.3', '7.4', '8.0', '8.1']
     steps:
       - uses: actions/checkout@v1
 
       - name: Setup PHP
-        uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
+        uses: shivammathur/setup-php@v2
         with:
           php-version: ${{ matrix.php }}
           extensions: gd, mbstring, json, curl, xml, mysql, ldap
@@ -40,7 +36,7 @@ jobs:
 
       - name: Start MySQL
         run: |
-          sudo /etc/init.d/mysql start
+          sudo systemctl start mysql
 
       - name: Create database & user
         run: |
index fc0f10a000593b26f6e41b17485ced22c0f7fbe3..0a858681c74ce49237a7086f99462705871bbbf0 100644 (file)
@@ -23,4 +23,5 @@ nbproject
 .settings/
 webpack-stats.json
 .phpunit.result.cache
-.DS_Store
\ No newline at end of file
+.DS_Store
+phpstan.neon
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 61aeaad8c8323f444c5f7b7af001f5e141dd03fa..0ec2e91ab4ee27057cdad87fefdbad2355c2613d 100644 (file)
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2020 Dan Brown and the BookStack Project contributors
+Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
 https://github.com/BookStackApp/BookStack/graphs/contributors
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
index c8590f0b25d12dc8f4f29210e51cd3c8b28de897..3b1408cb94ded300edfe39c978b1b3389b2474cc 100644 (file)
@@ -11,16 +11,15 @@ use Illuminate\Support\Str;
 
 /**
  * @property string $type
- * @property User $user
+ * @property User   $user
  * @property Entity $entity
  * @property string $detail
  * @property string $entity_type
- * @property int $entity_id
- * @property int $user_id
+ * @property int    $entity_id
+ * @property int    $user_id
  */
 class Activity extends Model
 {
-
     /**
      * Get the entity for this activity.
      */
@@ -29,6 +28,7 @@ class Activity extends Model
         if ($this->entity_type === '') {
             $this->entity_type = null;
         }
+
         return $this->morphTo('entity');
     }
 
@@ -54,14 +54,14 @@ class Activity extends Model
     public function isForEntity(): bool
     {
         return Str::startsWith($this->type, [
-            'page_', 'chapter_', 'book_', 'bookshelf_'
+            'page_', 'chapter_', 'book_', 'bookshelf_',
         ]);
     }
 
     /**
      * Checks if another Activity matches the general information of another.
      */
-    public function isSimilarTo(Activity $activityB): bool
+    public function isSimilarTo(self $activityB): bool
     {
         return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
     }
diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php
new file mode 100644 (file)
index 0000000..0d1391b
--- /dev/null
@@ -0,0 +1,115 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\Models\Entity;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\Log;
+
+class ActivityLogger
+{
+    protected $permissionService;
+
+    public function __construct(PermissionService $permissionService)
+    {
+        $this->permissionService = $permissionService;
+    }
+
+    /**
+     * Add a generic activity event to the database.
+     *
+     * @param string|Loggable $detail
+     */
+    public function add(string $type, $detail = '')
+    {
+        $detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
+
+        $activity = $this->newActivityForUser($type);
+        $activity->detail = $detailToStore;
+
+        if ($detail instanceof Entity) {
+            $activity->entity_id = $detail->id;
+            $activity->entity_type = $detail->getMorphClass();
+        }
+
+        $activity->save();
+        $this->setNotification($type);
+        $this->dispatchWebhooks($type, $detail);
+    }
+
+    /**
+     * Get a new activity instance for the current user.
+     */
+    protected function newActivityForUser(string $type): Activity
+    {
+        $ip = request()->ip() ?? '';
+
+        return (new Activity())->forceFill([
+            'type'     => strtolower($type),
+            'user_id'  => user()->id,
+            'ip'       => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
+        ]);
+    }
+
+    /**
+     * Removes the entity attachment from each of its activities
+     * and instead uses the 'extra' field with the entities name.
+     * Used when an entity is deleted.
+     */
+    public function removeEntity(Entity $entity)
+    {
+        $entity->activity()->update([
+            'detail'       => $entity->name,
+            'entity_id'    => null,
+            'entity_type'  => null,
+        ]);
+    }
+
+    /**
+     * Flashes a notification message to the session if an appropriate message is available.
+     */
+    protected function setNotification(string $type): void
+    {
+        $notificationTextKey = 'activities.' . $type . '_notification';
+        if (trans()->has($notificationTextKey)) {
+            $message = trans($notificationTextKey);
+            session()->flash('success', $message);
+        }
+    }
+
+    /**
+     * @param string|Loggable $detail
+     */
+    protected function dispatchWebhooks(string $type, $detail): void
+    {
+        $webhooks = Webhook::query()
+            ->whereHas('trackedEvents', function (Builder $query) use ($type) {
+                $query->where('event', '=', $type)
+                    ->orWhere('event', '=', 'all');
+            })
+            ->where('active', '=', true)
+            ->get();
+
+        foreach ($webhooks as $webhook) {
+            dispatch(new DispatchWebhookJob($webhook, $type, $detail));
+        }
+    }
+
+    /**
+     * Log out a failed login attempt, Providing the given username
+     * as part of the message if the '%u' string is used.
+     */
+    public function logFailedLogin(string $username)
+    {
+        $message = config('logging.failed_login.message');
+        if (!$message) {
+            return;
+        }
+
+        $message = str_replace('%u', $username, $message);
+        $channel = config('logging.failed_login.channel');
+        Log::channel($channel)->warning($message);
+    }
+}
diff --git a/app/Actions/ActivityQueries.php b/app/Actions/ActivityQueries.php
new file mode 100644 (file)
index 0000000..f900fbb
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\Relation;
+
+class ActivityQueries
+{
+    protected $permissionService;
+
+    public function __construct(PermissionService $permissionService)
+    {
+        $this->permissionService = $permissionService;
+    }
+
+    /**
+     * Gets the latest activity.
+     */
+    public function latest(int $count = 20, int $page = 0): array
+    {
+        $activityList = $this->permissionService
+            ->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
+            ->orderBy('created_at', 'desc')
+            ->with(['user', 'entity'])
+            ->skip($count * $page)
+            ->take($count)
+            ->get();
+
+        return $this->filterSimilar($activityList);
+    }
+
+    /**
+     * Gets the latest activity for an entity, Filtering out similar
+     * items to prevent a message activity list.
+     */
+    public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
+    {
+        /** @var array<string, int[]> $queryIds */
+        $queryIds = [$entity->getMorphClass() => [$entity->id]];
+
+        if ($entity instanceof Book) {
+            $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->scopes('visible')->pluck('id');
+        }
+        if ($entity instanceof Book || $entity instanceof Chapter) {
+            $queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id');
+        }
+
+        $query = Activity::query();
+        $query->where(function (Builder $query) use ($queryIds) {
+            foreach ($queryIds as $morphClass => $idArr) {
+                $query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
+                    $innerQuery->where('entity_type', '=', $morphClass)
+                        ->whereIn('entity_id', $idArr);
+                });
+            }
+        });
+
+        $activity = $query->orderBy('created_at', 'desc')
+            ->with(['entity' => function (Relation $query) {
+                $query->withTrashed();
+            }, 'user.avatar'])
+            ->skip($count * ($page - 1))
+            ->take($count)
+            ->get();
+
+        return $this->filterSimilar($activity);
+    }
+
+    /**
+     * Get the latest activity for a user, Filtering out similar items.
+     */
+    public function userActivity(User $user, int $count = 20, int $page = 0): array
+    {
+        $activityList = $this->permissionService
+            ->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
+            ->orderBy('created_at', 'desc')
+            ->where('user_id', '=', $user->id)
+            ->skip($count * $page)
+            ->take($count)
+            ->get();
+
+        return $this->filterSimilar($activityList);
+    }
+
+    /**
+     * Filters out similar activity.
+     *
+     * @param Activity[] $activities
+     */
+    protected function filterSimilar(iterable $activities): array
+    {
+        $newActivity = [];
+        $previousItem = null;
+
+        foreach ($activities as $activityItem) {
+            if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
+                $newActivity[] = $activityItem;
+            }
+
+            $previousItem = $activityItem;
+        }
+
+        return $newActivity;
+    }
+}
diff --git a/app/Actions/ActivityService.php b/app/Actions/ActivityService.php
deleted file mode 100644 (file)
index 73f827e..0000000
+++ /dev/null
@@ -1,192 +0,0 @@
-<?php namespace BookStack\Actions;
-
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Auth\User;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Entity;
-use BookStack\Entities\Models\Page;
-use BookStack\Interfaces\Loggable;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Eloquent\Relations\Relation;
-use Illuminate\Support\Facades\Log;
-
-class ActivityService
-{
-    protected $activity;
-    protected $permissionService;
-
-    public function __construct(Activity $activity, PermissionService $permissionService)
-    {
-        $this->activity = $activity;
-        $this->permissionService = $permissionService;
-    }
-
-    /**
-     * Add activity data to database for an entity.
-     */
-    public function addForEntity(Entity $entity, string $type)
-    {
-        $activity = $this->newActivityForUser($type);
-        $entity->activity()->save($activity);
-        $this->setNotification($type);
-    }
-
-    /**
-     * Add a generic activity event to the database.
-     * @param string|Loggable $detail
-     */
-    public function add(string $type, $detail = '')
-    {
-        if ($detail instanceof Loggable) {
-            $detail = $detail->logDescriptor();
-        }
-
-        $activity = $this->newActivityForUser($type);
-        $activity->detail = $detail;
-        $activity->save();
-        $this->setNotification($type);
-    }
-
-    /**
-     * Get a new activity instance for the current user.
-     */
-    protected function newActivityForUser(string $type): Activity
-    {
-        return $this->activity->newInstance()->forceFill([
-            'type'     => strtolower($type),
-            'user_id' => user()->id,
-        ]);
-    }
-
-    /**
-     * Removes the entity attachment from each of its activities
-     * and instead uses the 'extra' field with the entities name.
-     * Used when an entity is deleted.
-     */
-    public function removeEntity(Entity $entity)
-    {
-        $entity->activity()->update([
-            'detail'       => $entity->name,
-            'entity_id'   => null,
-            'entity_type' => null,
-        ]);
-    }
-
-    /**
-     * Gets the latest activity.
-     */
-    public function latest(int $count = 20, int $page = 0): array
-    {
-        $activityList = $this->permissionService
-            ->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
-            ->orderBy('created_at', 'desc')
-            ->with(['user', 'entity'])
-            ->skip($count * $page)
-            ->take($count)
-            ->get();
-
-        return $this->filterSimilar($activityList);
-    }
-
-    /**
-     * Gets the latest activity for an entity, Filtering out similar
-     * items to prevent a message activity list.
-     */
-    public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
-    {
-        /** @var [string => int[]] $queryIds */
-        $queryIds = [$entity->getMorphClass() => [$entity->id]];
-
-        if ($entity->isA('book')) {
-            $queryIds[(new Chapter)->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
-        }
-        if ($entity->isA('book') || $entity->isA('chapter')) {
-            $queryIds[(new Page)->getMorphClass()] = $entity->pages()->visible()->pluck('id');
-        }
-
-        $query = $this->activity->newQuery();
-        $query->where(function (Builder $query) use ($queryIds) {
-            foreach ($queryIds as $morphClass => $idArr) {
-                $query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
-                    $innerQuery->where('entity_type', '=', $morphClass)
-                        ->whereIn('entity_id', $idArr);
-                });
-            }
-        });
-
-        $activity = $query->orderBy('created_at', 'desc')
-            ->with(['entity' => function (Relation $query) {
-                $query->withTrashed();
-            }, 'user.avatar'])
-            ->skip($count * ($page - 1))
-            ->take($count)
-            ->get();
-
-        return $this->filterSimilar($activity);
-    }
-
-    /**
-     * Get latest activity for a user, Filtering out similar items.
-     */
-    public function userActivity(User $user, int $count = 20, int $page = 0): array
-    {
-        $activityList = $this->permissionService
-            ->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
-            ->orderBy('created_at', 'desc')
-            ->where('user_id', '=', $user->id)
-            ->skip($count * $page)
-            ->take($count)
-            ->get();
-
-        return $this->filterSimilar($activityList);
-    }
-
-    /**
-     * Filters out similar activity.
-     * @param Activity[] $activities
-     * @return array
-     */
-    protected function filterSimilar(iterable $activities): array
-    {
-        $newActivity = [];
-        $previousItem = null;
-
-        foreach ($activities as $activityItem) {
-            if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
-                $newActivity[] = $activityItem;
-            }
-
-            $previousItem = $activityItem;
-        }
-
-        return $newActivity;
-    }
-
-    /**
-     * Flashes a notification message to the session if an appropriate message is available.
-     */
-    protected function setNotification(string $type)
-    {
-        $notificationTextKey = 'activities.' . $type . '_notification';
-        if (trans()->has($notificationTextKey)) {
-            $message = trans($notificationTextKey);
-            session()->flash('success', $message);
-        }
-    }
-
-    /**
-     * Log out a failed login attempt, Providing the given username
-     * as part of the message if the '%u' string is used.
-     */
-    public function logFailedLogin(string $username)
-    {
-        $message = config('logging.failed_login.message');
-        if (!$message) {
-            return;
-        }
-
-        $message = str_replace("%u", $username, $message);
-        $channel = config('logging.failed_login.channel');
-        Log::channel($channel)->warning($message);
-    }
-}
index ec02bed25483696747c9fceceb6f9b560e4da819..8b5213a8b2af809f5e668f603a49d36f27dc1ecb 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 class ActivityType
 {
@@ -48,4 +50,19 @@ class ActivityType
     const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
     const AUTH_LOGIN = 'auth_login';
     const AUTH_REGISTER = 'auth_register';
+
+    const MFA_SETUP_METHOD = 'mfa_setup_method';
+    const MFA_REMOVE_METHOD = 'mfa_remove_method';
+
+    const WEBHOOK_CREATE = 'webhook_create';
+    const WEBHOOK_UPDATE = 'webhook_update';
+    const WEBHOOK_DELETE = 'webhook_delete';
+
+    /**
+     * Get all the possible values.
+     */
+    public static function all(): array
+    {
+        return (new \ReflectionClass(static::class))->getConstants();
+    }
 }
index f5269e2534d7a8ea2cbc77cbfdefa5adac815847..885ba6ed1ac72d991f7003025ac671ee9d8dd99d 100644 (file)
@@ -1,24 +1,29 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Model;
 use BookStack\Traits\HasCreatorAndUpdater;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 /**
- * @property string text
- * @property string html
- * @property int|null parent_id
- * @property int local_id
+ * @property int      $id
+ * @property string   $text
+ * @property string   $html
+ * @property int|null $parent_id
+ * @property int      $local_id
  */
 class Comment extends Model
 {
+    use HasFactory;
     use HasCreatorAndUpdater;
 
     protected $fillable = ['text', 'parent_id'];
     protected $appends = ['created', 'updated'];
 
     /**
-     * Get the entity that this comment belongs to
+     * Get the entity that this comment belongs to.
      */
     public function entity(): MorphTo
     {
@@ -35,6 +40,7 @@ class Comment extends Model
 
     /**
      * Get created date as a relative diff.
+     *
      * @return mixed
      */
     public function getCreatedAttribute()
@@ -44,6 +50,7 @@ class Comment extends Model
 
     /**
      * Get updated date as a relative diff.
+     *
      * @return mixed
      */
     public function getUpdatedAttribute()
index 13a83e7fdd247064983c64928811b49db2ba4ca1..2f2dd658a3c754294e8e45735ba183c5e2cc4d55 100644 (file)
@@ -1,21 +1,21 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Entities\Models\Entity;
-use League\CommonMark\CommonMarkConverter;
 use BookStack\Facades\Activity as ActivityService;
+use League\CommonMark\CommonMarkConverter;
 
 /**
- * Class CommentRepo
+ * Class CommentRepo.
  */
 class CommentRepo
 {
-
     /**
-     * @var Comment $comment
+     * @var Comment
      */
     protected $comment;
 
-
     public function __construct(Comment $comment)
     {
         $this->comment = $comment;
@@ -45,7 +45,8 @@ class CommentRepo
         $comment->parent_id = $parent_id;
 
         $entity->comments()->save($comment);
-        ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
+        ActivityService::add(ActivityType::COMMENTED_ON, $entity);
+
         return $comment;
     }
 
@@ -58,25 +59,26 @@ class CommentRepo
         $comment->text = $text;
         $comment->html = $this->commentToHtml($text);
         $comment->save();
+
         return $comment;
     }
 
     /**
      * Delete a comment from the system.
      */
-    public function delete(Comment $comment)
+    public function delete(Comment $comment): void
     {
         $comment->delete();
     }
 
     /**
-     * Convert the given comment markdown text to HTML.
+     * Convert the given comment Markdown to HTML.
      */
     public function commentToHtml(string $commentText): string
     {
         $converter = new CommonMarkConverter([
-            'html_input' => 'strip',
-            'max_nesting_level' => 10,
+            'html_input'         => 'strip',
+            'max_nesting_level'  => 10,
             'allow_unsafe_links' => false,
         ]);
 
@@ -88,7 +90,9 @@ class CommentRepo
      */
     protected function getNextLocalId(Entity $entity): int
     {
-        $comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
-        return ($comments->local_id ?? 0) + 1;
+        /** @var Comment $comment */
+        $comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
+
+        return ($comment->local_id ?? 0) + 1;
     }
 }
diff --git a/app/Actions/DispatchWebhookJob.php b/app/Actions/DispatchWebhookJob.php
new file mode 100644 (file)
index 0000000..8f78150
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
+use BookStack\Facades\Theme;
+use BookStack\Interfaces\Loggable;
+use BookStack\Model;
+use BookStack\Theming\ThemeEvents;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+class DispatchWebhookJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    /**
+     * @var Webhook
+     */
+    protected $webhook;
+
+    /**
+     * @var string
+     */
+    protected $event;
+
+    /**
+     * @var string|Loggable
+     */
+    protected $detail;
+
+    /**
+     * @var User
+     */
+    protected $initiator;
+
+    /**
+     * @var int
+     */
+    protected $initiatedTime;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Webhook $webhook, string $event, $detail)
+    {
+        $this->webhook = $webhook;
+        $this->event = $event;
+        $this->detail = $detail;
+        $this->initiator = user();
+        $this->initiatedTime = time();
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
+        $webhookData = $themeResponse ?? $this->buildWebhookData();
+        $lastError = null;
+
+        try {
+            $response = Http::asJson()
+                ->withOptions(['allow_redirects' => ['strict' => true]])
+                ->timeout($this->webhook->timeout)
+                ->post($this->webhook->endpoint, $webhookData);
+        } catch (\Exception $exception) {
+            $lastError = $exception->getMessage();
+            Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
+        }
+
+        if (isset($response) && $response->failed()) {
+            $lastError = "Response status from endpoint was {$response->status()}";
+            Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
+        }
+
+        $this->webhook->last_called_at = now();
+        if ($lastError) {
+            $this->webhook->last_errored_at = now();
+            $this->webhook->last_error = $lastError;
+        }
+
+        $this->webhook->save();
+    }
+
+    protected function buildWebhookData(): array
+    {
+        $textParts = [
+            $this->initiator->name,
+            trans('activities.' . $this->event),
+        ];
+
+        if ($this->detail instanceof Entity) {
+            $textParts[] = '"' . $this->detail->name . '"';
+        }
+
+        $data = [
+            'event'                    => $this->event,
+            'text'                     => implode(' ', $textParts),
+            'triggered_at'             => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
+            'triggered_by'             => $this->initiator->attributesToArray(),
+            'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
+            'webhook_id'               => $this->webhook->id,
+            'webhook_name'             => $this->webhook->name,
+        ];
+
+        if (method_exists($this->detail, 'getUrl')) {
+            $data['url'] = $this->detail->getUrl();
+        }
+
+        if ($this->detail instanceof Model) {
+            $data['related_item'] = $this->detail->attributesToArray();
+        }
+
+        return $data;
+    }
+}
diff --git a/app/Actions/Favourite.php b/app/Actions/Favourite.php
new file mode 100644 (file)
index 0000000..f458941
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+class Favourite extends Model
+{
+    protected $fillable = ['user_id'];
+
+    /**
+     * Get the related model that can be favourited.
+     */
+    public function favouritable(): MorphTo
+    {
+        return $this->morphTo();
+    }
+}
index 5968ffe6d5ea9d875cf4fa574ac63d3d4c8d62a1..609c299ad887f2758c973ec197f1b572f1a11eb2 100644 (file)
@@ -1,18 +1,45 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
 
+/**
+ * @property int    $id
+ * @property string $name
+ * @property string $value
+ * @property int    $order
+ */
 class Tag extends Model
 {
+    use HasFactory;
+
     protected $fillable = ['name', 'value', 'order'];
     protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
 
     /**
-     * Get the entity that this tag belongs to
-     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+     * Get the entity that this tag belongs to.
      */
-    public function entity()
+    public function entity(): MorphTo
     {
         return $this->morphTo('entity');
     }
+
+    /**
+     * Get a full URL to start a tag name search for this tag name.
+     */
+    public function nameUrl(): string
+    {
+        return url('/search?term=%5B' . urlencode($this->name) . '%5D');
+    }
+
+    /**
+     * Get a full URL to start a tag name and value search for this tag's values.
+     */
+    public function valueUrl(): string
+    {
+        return url('/search?term=%5B' . urlencode($this->name) . '%3D' . urlencode($this->value) . '%5D');
+    }
 }
index c80e8abe3225e70a121aca5d21873ec1fda1a6a7..0acee7486b92a4dec062834799b99a406e6bba39 100644 (file)
@@ -1,23 +1,57 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Models\Entity;
-use DB;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
 
 class TagRepo
 {
-
     protected $tag;
     protected $permissionService;
 
+    public function __construct(PermissionService $ps)
+    {
+        $this->permissionService = $ps;
+    }
+
     /**
-     * TagRepo constructor.
+     * Start a query against all tags in the system.
      */
-    public function __construct(Tag $tag, PermissionService $ps)
+    public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
     {
-        $this->tag = $tag;
-        $this->permissionService = $ps;
+        $query = Tag::query()
+            ->select([
+                'name',
+                ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
+                DB::raw('COUNT(id) as usages'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
+            ])
+            ->orderBy($nameFilter ? 'value' : 'name');
+
+        if ($nameFilter) {
+            $query->where('name', '=', $nameFilter);
+            $query->groupBy('value');
+        } elseif ($searchTerm) {
+            $query->groupBy('name', 'value');
+        } else {
+            $query->groupBy('name');
+        }
+
+        if ($searchTerm) {
+            $query->where(function (Builder $query) use ($searchTerm) {
+                $query->where('name', 'like', '%' . $searchTerm . '%')
+                    ->orWhere('value', 'like', '%' . $searchTerm . '%');
+            });
+        }
+
+        return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
     }
 
     /**
@@ -26,7 +60,7 @@ class TagRepo
      */
     public function getNameSuggestions(?string $searchTerm): Collection
     {
-        $query = $this->tag->newQuery()
+        $query = Tag::query()
             ->select('*', DB::raw('count(*) as count'))
             ->groupBy('name');
 
@@ -37,6 +71,7 @@ class TagRepo
         }
 
         $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+
         return $query->get(['name'])->pluck('name');
     }
 
@@ -47,7 +82,7 @@ class TagRepo
      */
     public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
     {
-        $query = $this->tag->newQuery()
+        $query = Tag::query()
             ->select('*', DB::raw('count(*) as count'))
             ->groupBy('value');
 
@@ -62,11 +97,12 @@ class TagRepo
         }
 
         $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+
         return $query->get(['value'])->pluck('value');
     }
 
     /**
-     * Save an array of tags to an entity
+     * Save an array of tags to an entity.
      */
     public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
     {
@@ -87,8 +123,9 @@ class TagRepo
      */
     protected function newInstanceFromInput(array $input): Tag
     {
-        $name = trim($input['name']);
-        $value = isset($input['value']) ? trim($input['value']) : '';
-        return $this->tag->newInstance(['name' => $name, 'value' => $value]);
+        return new Tag([
+            'name'  => trim($input['name']),
+            'value' => trim($input['value'] ?? ''),
+        ]);
     }
 }
index e9841293b5c72a1d20f045b81e8f95a7a534a9e0..16961bd914bef8b29fc659d6f689fd7f56b99376 100644 (file)
@@ -1,18 +1,58 @@
-<?php namespace BookStack\Actions;
+<?php
 
+namespace BookStack\Actions;
+
+use BookStack\Interfaces\Viewable;
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
 
+/**
+ * Class View
+ * Views are stored per-item per-person within the database.
+ * They can be used to find popular items or recently viewed items
+ * at a per-person level. They do not record every view instance as an
+ * activity. Only the latest and original view times could be recognised.
+ *
+ * @property int $views
+ * @property int $user_id
+ */
 class View extends Model
 {
-
     protected $fillable = ['user_id', 'views'];
 
     /**
      * Get all owning viewable models.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
      */
-    public function viewable()
+    public function viewable(): MorphTo
     {
         return $this->morphTo();
     }
+
+    /**
+     * Increment the current user's view count for the given viewable model.
+     */
+    public static function incrementFor(Viewable $viewable): int
+    {
+        $user = user();
+        if (is_null($user) || $user->isDefault()) {
+            return 0;
+        }
+
+        /** @var View $view */
+        $view = $viewable->views()->firstOrNew([
+            'user_id' => $user->id,
+        ], ['views' => 0]);
+
+        $view->forceFill(['views' => $view->views + 1])->save();
+
+        return $view->views;
+    }
+
+    /**
+     * Clear all views from the system.
+     */
+    public static function clearAll()
+    {
+        static::query()->truncate();
+    }
 }
diff --git a/app/Actions/ViewService.php b/app/Actions/ViewService.php
deleted file mode 100644 (file)
index a4e620d..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-<?php namespace BookStack\Actions;
-
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Entity;
-use BookStack\Entities\EntityProvider;
-use DB;
-use Illuminate\Support\Collection;
-
-class ViewService
-{
-    protected $view;
-    protected $permissionService;
-    protected $entityProvider;
-
-    /**
-     * ViewService constructor.
-     * @param View $view
-     * @param PermissionService $permissionService
-     * @param EntityProvider $entityProvider
-     */
-    public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
-    {
-        $this->view = $view;
-        $this->permissionService = $permissionService;
-        $this->entityProvider = $entityProvider;
-    }
-
-    /**
-     * Add a view to the given entity.
-     * @param \BookStack\Entities\Models\Entity $entity
-     * @return int
-     */
-    public function add(Entity $entity)
-    {
-        $user = user();
-        if ($user === null || $user->isDefault()) {
-            return 0;
-        }
-        $view = $entity->views()->where('user_id', '=', $user->id)->first();
-        // Add view if model exists
-        if ($view) {
-            $view->increment('views');
-            return $view->views;
-        }
-
-        // Otherwise create new view count
-        $entity->views()->save($this->view->newInstance([
-            'user_id' => $user->id,
-            'views' => 1
-        ]));
-
-        return 1;
-    }
-
-    /**
-     * Get the entities with the most views.
-     * @param int $count
-     * @param int $page
-     * @param string|array $filterModels
-     * @param string $action - used for permission checking
-     * @return Collection
-     */
-    public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
-    {
-        $skipCount = $count * $page;
-        $query = $this->permissionService
-            ->filterRestrictedEntityRelations($this->view->newQuery(), 'views', 'viewable_id', 'viewable_type', $action)
-            ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
-            ->groupBy('viewable_id', 'viewable_type')
-            ->orderBy('view_count', 'desc');
-
-        if ($filterModels) {
-            $query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
-        }
-
-        return $query->with('viewable')
-            ->skip($skipCount)
-            ->take($count)
-            ->get()
-            ->pluck('viewable')
-            ->filter();
-    }
-
-    /**
-     * Get all recently viewed entities for the current user.
-     */
-    public function getUserRecentlyViewed(int $count = 10, int $page = 1)
-    {
-        $user = user();
-        if ($user === null || $user->isDefault()) {
-            return collect();
-        }
-
-        $all = collect();
-        /** @var Entity $instance */
-        foreach ($this->entityProvider->all() as $name => $instance) {
-            $items = $instance::visible()->withLastView()
-                ->having('last_viewed_at', '>', 0)
-                ->orderBy('last_viewed_at', 'desc')
-                ->skip($count * ($page - 1))
-                ->take($count)
-                ->get();
-            $all = $all->concat($items);
-        }
-
-        return $all->sortByDesc('last_viewed_at')->slice(0, $count);
-    }
-
-    /**
-     * Reset all view counts by deleting all views.
-     */
-    public function resetAll()
-    {
-        $this->view->truncate();
-    }
-}
diff --git a/app/Actions/Webhook.php b/app/Actions/Webhook.php
new file mode 100644 (file)
index 0000000..72a67ad
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Interfaces\Loggable;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+/**
+ * @property int        $id
+ * @property string     $name
+ * @property string     $endpoint
+ * @property Collection $trackedEvents
+ * @property bool       $active
+ * @property int        $timeout
+ * @property string     $last_error
+ * @property Carbon     $last_called_at
+ * @property Carbon     $last_errored_at
+ */
+class Webhook extends Model implements Loggable
+{
+    protected $fillable = ['name', 'endpoint', 'timeout'];
+
+    use HasFactory;
+
+    protected $casts = [
+        'last_called_at'  => 'datetime',
+        'last_errored_at' => 'datetime',
+    ];
+
+    /**
+     * Define the tracked event relation a webhook.
+     */
+    public function trackedEvents(): HasMany
+    {
+        return $this->hasMany(WebhookTrackedEvent::class);
+    }
+
+    /**
+     * Update the tracked events for a webhook from the given list of event types.
+     */
+    public function updateTrackedEvents(array $events): void
+    {
+        $this->trackedEvents()->delete();
+
+        $eventsToStore = array_intersect($events, array_values(ActivityType::all()));
+        if (in_array('all', $events)) {
+            $eventsToStore = ['all'];
+        }
+
+        $trackedEvents = [];
+        foreach ($eventsToStore as $event) {
+            $trackedEvents[] = new WebhookTrackedEvent(['event' => $event]);
+        }
+
+        $this->trackedEvents()->saveMany($trackedEvents);
+    }
+
+    /**
+     * Check if this webhook tracks the given event.
+     */
+    public function tracksEvent(string $event): bool
+    {
+        return $this->trackedEvents->pluck('event')->contains($event);
+    }
+
+    /**
+     * Get a URL for this webhook within the settings interface.
+     */
+    public function getUrl(string $path = ''): string
+    {
+        return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/'));
+    }
+
+    /**
+     * Get the string descriptor for this item.
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}";
+    }
+}
diff --git a/app/Actions/WebhookTrackedEvent.php b/app/Actions/WebhookTrackedEvent.php
new file mode 100644 (file)
index 0000000..6289581
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace BookStack\Actions;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int    $id
+ * @property int    $webhook_id
+ * @property string $event
+ */
+class WebhookTrackedEvent extends Model
+{
+    protected $fillable = ['event'];
+
+    use HasFactory;
+}
index 8b520eda2b7432e67c49228e73a349495993fa83..4cba7900b7942adca030e193647b99f0622dc6c5 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Api;
+<?php
+
+namespace BookStack\Api;
 
 use BookStack\Http\Controllers\Api\ApiController;
 use Illuminate\Contracts\Container\BindingResolutionException;
@@ -12,7 +14,6 @@ use ReflectionMethod;
 
 class ApiDocsGenerator
 {
-
     protected $reflectionClasses = [];
     protected $controllerClasses = [];
 
@@ -27,9 +28,10 @@ class ApiDocsGenerator
         if (Cache::has($cacheKey) && config('app.env') === 'production') {
             $docs = Cache::get($cacheKey);
         } else {
-            $docs = (new static())->generate();
+            $docs = (new ApiDocsGenerator())->generate();
             Cache::put($cacheKey, $docs, 60 * 24);
         }
+
         return $docs;
     }
 
@@ -42,6 +44,7 @@ class ApiDocsGenerator
         $apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
         $apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
         $apiRoutes = $apiRoutes->groupBy('base_model');
+
         return $apiRoutes;
     }
 
@@ -52,11 +55,18 @@ class ApiDocsGenerator
     {
         return $routes->map(function (array $route) {
             $exampleTypes = ['request', 'response'];
+            $fileTypes = ['json', 'http'];
             foreach ($exampleTypes as $exampleType) {
-                $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json");
-                $exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
-                $route["example_{$exampleType}"] = $exampleContent;
+                foreach ($fileTypes as $fileType) {
+                    $exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}." . $fileType);
+                    if (file_exists($exampleFile)) {
+                        $route["example_{$exampleType}"] = file_get_contents($exampleFile);
+                        continue 2;
+                    }
+                }
+                $route["example_{$exampleType}"] = null;
             }
+
             return $route;
         });
     }
@@ -71,12 +81,14 @@ class ApiDocsGenerator
             $comment = $method->getDocComment();
             $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
             $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
+
             return $route;
         });
     }
 
     /**
      * Load body params and their rules by inspecting the given class and method name.
+     *
      * @throws BindingResolutionException
      */
     protected function getBodyParamsFromClass(string $className, string $methodName): ?array
@@ -89,24 +101,24 @@ class ApiDocsGenerator
         }
 
         $rules = $class->getValdationRules()[$methodName] ?? [];
-        foreach ($rules as $param => $ruleString) {
-            $rules[$param] = explode('|', $ruleString);
-        }
-        return count($rules) > 0 ? $rules : null;
+
+        return empty($rules) ? null : $rules;
     }
 
     /**
      * Parse out the description text from a class method comment.
      */
-    protected function parseDescriptionFromMethodComment(string $comment)
+    protected function parseDescriptionFromMethodComment(string $comment): string
     {
         $matches = [];
         preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
+
         return implode(' ', $matches[1] ?? []);
     }
 
     /**
      * Get a reflection method from the given class name and method name.
+     *
      * @throws ReflectionException
      */
     protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
@@ -131,14 +143,15 @@ class ApiDocsGenerator
             [$controller, $controllerMethod] = explode('@', $route->action['uses']);
             $baseModelName = explode('.', explode('/', $route->uri)[1])[0];
             $shortName = $baseModelName . '-' . $controllerMethod;
+
             return [
-                'name' => $shortName,
-                'uri' => $route->uri,
-                'method' => $route->methods[0],
-                'controller' => $controller,
-                'controller_method' => $controllerMethod,
+                'name'                    => $shortName,
+                'uri'                     => $route->uri,
+                'method'                  => $route->methods[0],
+                'controller'              => $controller,
+                'controller_method'       => $controllerMethod,
                 'controller_method_kebab' => Str::kebab($controllerMethod),
-                'base_model' => $baseModelName,
+                'base_model'              => $baseModelName,
             ];
         });
     }
index defaa7e954af69354fc3f3c637720f392fbc005c..70b289ae17e2668159a48b87b792d61a38be4f41 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Api;
+<?php
+
+namespace BookStack\Api;
 
 use BookStack\Auth\User;
 use BookStack\Interfaces\Loggable;
@@ -7,19 +9,20 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Support\Carbon;
 
 /**
- * Class ApiToken
- * @property int $id
+ * Class ApiToken.
+ *
+ * @property int    $id
  * @property string $token_id
  * @property string $secret
  * @property string $name
  * @property Carbon $expires_at
- * @property User $user
+ * @property User   $user
  */
 class ApiToken extends Model implements Loggable
 {
     protected $fillable = ['name', 'expires_at'];
     protected $casts = [
-        'expires_at' => 'date:Y-m-d'
+        'expires_at' => 'date:Y-m-d',
     ];
 
     /**
@@ -40,7 +43,7 @@ class ApiToken extends Model implements Loggable
     }
 
     /**
-     * @inheritdoc
+     * {@inheritdoc}
      */
     public function logDescriptor(): string
     {
index 59ab72f4eb8509be037704fdddff907bda4403b4..1bb672556dfa2dec6d008a2078247cd34a7a1d7e 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Api;
 
+use BookStack\Auth\Access\LoginService;
 use BookStack\Exceptions\ApiAuthException;
 use Illuminate\Auth\GuardHelpers;
 use Illuminate\Contracts\Auth\Authenticatable;
@@ -12,7 +13,6 @@ use Symfony\Component\HttpFoundation\Request;
 
 class ApiTokenGuard implements Guard
 {
-
     use GuardHelpers;
 
     /**
@@ -20,9 +20,14 @@ class ApiTokenGuard implements Guard
      */
     protected $request;
 
+    /**
+     * @var LoginService
+     */
+    protected $loginService;
 
     /**
      * The last auth exception thrown in this request.
+     *
      * @var ApiAuthException
      */
     protected $lastAuthException;
@@ -30,13 +35,14 @@ class ApiTokenGuard implements Guard
     /**
      * ApiTokenGuard constructor.
      */
-    public function __construct(Request $request)
+    public function __construct(Request $request, LoginService $loginService)
     {
         $this->request = $request;
+        $this->loginService = $loginService;
     }
-    
+
     /**
-     * @inheritDoc
+     * {@inheritdoc}
      */
     public function user()
     {
@@ -47,6 +53,7 @@ class ApiTokenGuard implements Guard
         }
 
         $user = null;
+
         try {
             $user = $this->getAuthorisedUserFromRequest();
         } catch (ApiAuthException $exception) {
@@ -54,19 +61,20 @@ class ApiTokenGuard implements Guard
         }
 
         $this->user = $user;
+
         return $user;
     }
 
     /**
      * Determine if current user is authenticated. If not, throw an exception.
      *
-     * @return \Illuminate\Contracts\Auth\Authenticatable
-     *
      * @throws ApiAuthException
+     *
+     * @return \Illuminate\Contracts\Auth\Authenticatable
      */
     public function authenticate()
     {
-        if (! is_null($user = $this->user())) {
+        if (!is_null($user = $this->user())) {
             return $user;
         }
 
@@ -79,6 +87,7 @@ class ApiTokenGuard implements Guard
 
     /**
      * Check the API token in the request and fetch a valid authorised user.
+     *
      * @throws ApiAuthException
      */
     protected function getAuthorisedUserFromRequest(): Authenticatable
@@ -93,11 +102,16 @@ class ApiTokenGuard implements Guard
 
         $this->validateToken($token, $secret);
 
+        if ($this->loginService->awaitingEmailConfirmation($token->user)) {
+            throw new ApiAuthException(trans('errors.email_confirmation_awaiting'));
+        }
+
         return $token->user;
     }
 
     /**
      * Validate the format of the token header value string.
+     *
      * @throws ApiAuthException
      */
     protected function validateTokenHeaderValue(string $authToken): void
@@ -114,6 +128,7 @@ class ApiTokenGuard implements Guard
     /**
      * Validate the given secret against the given token and ensure the token
      * currently has access to the instance API.
+     *
      * @throws ApiAuthException
      */
     protected function validateToken(?ApiToken $token, string $secret): void
@@ -137,7 +152,7 @@ class ApiTokenGuard implements Guard
     }
 
     /**
-     * @inheritDoc
+     * {@inheritdoc}
      */
     public function validate(array $credentials = [])
     {
index 06802808ef5b1757a828d7884ff474c3f9965ba2..3dbe954b8b7693bbeb0ec5c43bbfe948b2814b7f 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Api;
+<?php
+
+namespace BookStack\Api;
 
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
@@ -6,7 +8,6 @@ use Illuminate\Http\Request;
 
 class ListingResponseBuilder
 {
-
     protected $query;
     protected $request;
     protected $fields;
@@ -19,7 +20,7 @@ class ListingResponseBuilder
         'lt'   => '<',
         'gte'  => '>=',
         'lte'  => '<=',
-        'like' => 'like'
+        'like' => 'like',
     ];
 
     /**
@@ -45,7 +46,7 @@ class ListingResponseBuilder
         $data = $data->makeVisible($this->hiddenFields);
 
         return response()->json([
-            'data' => $data,
+            'data'  => $data,
             'total' => $total,
         ]);
     }
@@ -57,6 +58,7 @@ class ListingResponseBuilder
     {
         $query = $this->countAndOffsetQuery($query);
         $query = $this->sortQuery($query);
+
         return $query->get($this->fields);
     }
 
@@ -98,6 +100,7 @@ class ListingResponseBuilder
         }
 
         $queryOperator = $this->filterOperators[$filterOperator];
+
         return [$field, $queryOperator, $value];
     }
 
index 499fdeaa691ce8a0442b2494c9f46b09edf42f27..d409d14bc26906dd853d7bcf7f570d2d6b9efd5e 100644 (file)
@@ -4,11 +4,11 @@ namespace BookStack;
 
 class Application extends \Illuminate\Foundation\Application
 {
-
     /**
      * Get the path to the application configuration files.
      *
-     * @param  string  $path Optionally, a path to append to the config path
+     * @param string $path Optionally, a path to append to the config path
+     *
      * @return string
      */
     public function configPath($path = '')
@@ -18,6 +18,6 @@ class Application extends \Illuminate\Foundation\Application
             . 'app'
             . DIRECTORY_SEPARATOR
             . 'Config'
-            . ($path ? DIRECTORY_SEPARATOR.$path : $path);
+            . ($path ? DIRECTORY_SEPARATOR . $path : $path);
     }
 }
index 9aa3b9b98b56cf0d56453ae9e2de18c48c4d4524..9c357d95f955f8dfde8227bd3c4525fd056d5d21 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\User;
 use BookStack\Exceptions\ConfirmationEmailException;
@@ -12,7 +14,7 @@ class EmailConfirmationService extends UserTokenService
     /**
      * Create new confirmation for a user,
      * Also removes any existing old ones.
-     * @param User $user
+     *
      * @throws ConfirmationEmailException
      */
     public function sendConfirmation(User $user)
@@ -29,9 +31,8 @@ class EmailConfirmationService extends UserTokenService
 
     /**
      * Check if confirmation is required in this instance.
-     * @return bool
      */
-    public function confirmationRequired() : bool
+    public function confirmationRequired(): bool
     {
         return setting('registration-confirmation')
             || setting('registration-restrict');
index 69295ee4e900188cc2c7c3097b0a3c3379195b57..122425c1157f33618049b550ca51169a9c9eef98 100644 (file)
@@ -4,10 +4,10 @@ namespace BookStack\Auth\Access;
 
 use Illuminate\Contracts\Auth\Authenticatable;
 use Illuminate\Contracts\Auth\UserProvider;
+use Illuminate\Database\Eloquent\Model;
 
 class ExternalBaseUserProvider implements UserProvider
 {
-
     /**
      * The user model.
      *
@@ -17,7 +17,6 @@ class ExternalBaseUserProvider implements UserProvider
 
     /**
      * LdapUserProvider constructor.
-     * @param             $model
      */
     public function __construct(string $model)
     {
@@ -27,19 +26,21 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Create a new instance of the model.
      *
-     * @return \Illuminate\Database\Eloquent\Model
+     * @return Model
      */
     public function createModel()
     {
         $class = '\\' . ltrim($this->model, '\\');
-        return new $class;
+
+        return new $class();
     }
 
     /**
      * Retrieve a user by their unique identifier.
      *
-     * @param  mixed $identifier
-     * @return \Illuminate\Contracts\Auth\Authenticatable|null
+     * @param mixed $identifier
+     *
+     * @return Authenticatable|null
      */
     public function retrieveById($identifier)
     {
@@ -49,21 +50,22 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Retrieve a user by their unique identifier and "remember me" token.
      *
-     * @param  mixed  $identifier
-     * @param  string $token
-     * @return \Illuminate\Contracts\Auth\Authenticatable|null
+     * @param mixed  $identifier
+     * @param string $token
+     *
+     * @return Authenticatable|null
      */
     public function retrieveByToken($identifier, $token)
     {
         return null;
     }
 
-
     /**
      * Update the "remember me" token for the given user in storage.
      *
-     * @param  \Illuminate\Contracts\Auth\Authenticatable $user
-     * @param  string                                     $token
+     * @param Authenticatable $user
+     * @param string          $token
+     *
      * @return void
      */
     public function updateRememberToken(Authenticatable $user, $token)
@@ -74,13 +76,15 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Retrieve a user by the given credentials.
      *
-     * @param  array $credentials
-     * @return \Illuminate\Contracts\Auth\Authenticatable|null
+     * @param array $credentials
+     *
+     * @return Authenticatable|null
      */
     public function retrieveByCredentials(array $credentials)
     {
         // Search current user base by looking up a uid
         $model = $this->createModel();
+
         return $model->newQuery()
             ->where('external_auth_id', $credentials['external_auth_id'])
             ->first();
@@ -89,8 +93,9 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Validate a user against the given credentials.
      *
-     * @param  \Illuminate\Contracts\Auth\Authenticatable $user
-     * @param  array                                      $credentials
+     * @param Authenticatable $user
+     * @param array           $credentials
+     *
      * @return bool
      */
     public function validateCredentials(Authenticatable $user, array $credentials)
similarity index 86%
rename from app/Auth/Access/ExternalAuthService.php
rename to app/Auth/Access/GroupSyncService.php
index 4c71db21adff7a559b929353ec4974512c16df14..db19b007ac32b31f5a2bb6112514b6aa7b271826 100644 (file)
@@ -1,12 +1,12 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\DB;
 
-class ExternalAuthService
+class GroupSyncService
 {
     /**
      * Check a role against an array of group names to see if it matches.
@@ -19,6 +19,7 @@ class ExternalAuthService
         }
 
         $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
+
         return in_array($roleName, $groupNames);
     }
 
@@ -57,15 +58,15 @@ class ExternalAuthService
     }
 
     /**
-     * Sync the groups to the user roles for the current user
+     * Sync the groups to the user roles for the current user.
      */
-    public function syncWithGroups(User $user, array $userGroups): void
+    public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
     {
         // Get the ids for the roles from the names
         $groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
 
         // Sync groups
-        if ($this->config['remove_from_groups']) {
+        if ($detachExisting) {
             $user->roles()->sync($groupsAsRoles);
             $user->attachDefaultRole();
         } else {
similarity index 85%
rename from app/Auth/Access/Guards/Saml2SessionGuard.php
rename to app/Auth/Access/Guards/AsyncExternalBaseSessionGuard.php
index 044c2f3833f3b2f4652bf22c210c775efbc1b583..6677f5b108393a1b7d1bc68fc6b1605a826cacf1 100644 (file)
@@ -3,19 +3,20 @@
 namespace BookStack\Auth\Access\Guards;
 
 /**
- * Saml2 Session Guard
+ * Saml2 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.
  */
-class Saml2SessionGuard extends ExternalBaseSessionGuard
+class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
 {
     /**
      * Validate a user's credentials.
      *
      * @param array $credentials
+     *
      * @return bool
      */
     public function validate(array $credentials = [])
@@ -27,7 +28,8 @@ class Saml2SessionGuard extends ExternalBaseSessionGuard
      * Attempt to authenticate a user using the given credentials.
      *
      * @param array $credentials
-     * @param bool $remember
+     * @param bool  $remember
+     *
      * @return bool
      */
     public function attempt(array $credentials = [], $remember = false)
index b133754d8e932f43c750ab40b35fab224904d107..99bfd2e795ecd4f12198d21ea2ae97d39062af87 100644 (file)
@@ -84,7 +84,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
         // If we've already retrieved the user for the current request we can just
         // return it back immediately. We do not want to fetch the user data on
         // every call to this method because that would be tremendously slow.
-        if (! is_null($this->user)) {
+        if (!is_null($this->user)) {
             return $this->user;
         }
 
@@ -92,7 +92,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
         // First we will try to load the user using the
         // identifier in the session if one exists.
-        if (! is_null($id)) {
+        if (!is_null($id)) {
             $this->user = $this->provider->retrieveById($id);
         }
 
@@ -118,7 +118,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Log a user into the application without sessions or cookies.
      *
-     * @param  array  $credentials
+     * @param array $credentials
+     *
      * @return bool
      */
     public function once(array $credentials = [])
@@ -135,12 +136,13 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Log the given user ID into the application without sessions or cookies.
      *
-     * @param  mixed  $id
+     * @param mixed $id
+     *
      * @return \Illuminate\Contracts\Auth\Authenticatable|false
      */
     public function onceUsingId($id)
     {
-        if (! is_null($user = $this->provider->retrieveById($id))) {
+        if (!is_null($user = $this->provider->retrieveById($id))) {
             $this->setUser($user);
 
             return $user;
@@ -152,7 +154,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Validate a user's credentials.
      *
-     * @param  array  $credentials
+     * @param array $credentials
+     *
      * @return bool
      */
     public function validate(array $credentials = [])
@@ -160,12 +163,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
         return false;
     }
 
-
     /**
      * Attempt to authenticate a user using the given credentials.
      *
-     * @param  array  $credentials
-     * @param  bool  $remember
+     * @param array $credentials
+     * @param bool  $remember
+     *
      * @return bool
      */
     public function attempt(array $credentials = [], $remember = false)
@@ -176,26 +179,24 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Log the given user ID into the application.
      *
-     * @param  mixed  $id
-     * @param  bool  $remember
+     * @param mixed $id
+     * @param bool  $remember
+     *
      * @return \Illuminate\Contracts\Auth\Authenticatable|false
      */
     public function loginUsingId($id, $remember = false)
     {
-        if (! is_null($user = $this->provider->retrieveById($id))) {
-            $this->login($user, $remember);
-
-            return $user;
-        }
-
+        // Always return false as to disable this method,
+        // Logins should route through LoginService.
         return false;
     }
 
     /**
      * Log a user into the application.
      *
-     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
-     * @param  bool  $remember
+     * @param \Illuminate\Contracts\Auth\Authenticatable $user
+     * @param bool                                       $remember
+     *
      * @return void
      */
     public function login(AuthenticatableContract $user, $remember = false)
@@ -208,7 +209,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Update the session with the given ID.
      *
-     * @param  string  $id
+     * @param string $id
+     *
      * @return void
      */
     protected function updateSession($id)
@@ -262,7 +264,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
      */
     public function getName()
     {
-        return 'login_'.$this->name.'_'.sha1(static::class);
+        return 'login_' . $this->name . '_' . sha1(static::class);
     }
 
     /**
@@ -288,7 +290,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
     /**
      * Set the current user.
      *
-     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
+     * @param \Illuminate\Contracts\Auth\Authenticatable $user
+     *
      * @return $this
      */
     public function setUser(AuthenticatableContract $user)
index cabbfbbcbb7ac17065f0446fec76af95d9a362a0..078487224b20b5fa3b547cf536d8c3fb7aa69c22 100644 (file)
@@ -6,8 +6,8 @@ use BookStack\Auth\Access\LdapService;
 use BookStack\Auth\Access\RegistrationService;
 use BookStack\Auth\User;
 use BookStack\Exceptions\LdapException;
-use BookStack\Exceptions\LoginAttemptException;
 use BookStack\Exceptions\LoginAttemptEmailNeededException;
+use BookStack\Exceptions\LoginAttemptException;
 use BookStack\Exceptions\UserRegistrationException;
 use Illuminate\Contracts\Auth\UserProvider;
 use Illuminate\Contracts\Session\Session;
@@ -15,7 +15,6 @@ use Illuminate\Support\Str;
 
 class LdapSessionGuard extends ExternalBaseSessionGuard
 {
-
     protected $ldapService;
 
     /**
@@ -36,8 +35,10 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
      * Validate a user's credentials.
      *
      * @param array $credentials
-     * @return bool
+     *
      * @throws LdapException
+     *
+     * @return bool
      */
     public function validate(array $credentials = [])
     {
@@ -45,7 +46,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
 
         if (isset($userDetails['uid'])) {
             $this->lastAttempted = $this->provider->retrieveByCredentials([
-                'external_auth_id' => $userDetails['uid']
+                'external_auth_id' => $userDetails['uid'],
             ]);
         }
 
@@ -56,10 +57,12 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
      * Attempt to authenticate a user using the given credentials.
      *
      * @param array $credentials
-     * @param bool $remember
-     * @return bool
+     * @param bool  $remember
+     *
      * @throws LoginAttemptException
      * @throws LdapException
+     *
+     * @return bool
      */
     public function attempt(array $credentials = [], $remember = false)
     {
@@ -69,7 +72,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
         $user = null;
         if (isset($userDetails['uid'])) {
             $this->lastAttempted = $user = $this->provider->retrieveByCredentials([
-                'external_auth_id' => $userDetails['uid']
+                'external_auth_id' => $userDetails['uid'],
             ]);
         }
 
@@ -90,12 +93,19 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
             $this->ldapService->syncGroups($user, $username);
         }
 
+        // Attach avatar if non-existent
+        if (!$user->avatar()->exists()) {
+            $this->ldapService->saveAndAttachAvatar($user, $userDetails);
+        }
+
         $this->login($user, $remember);
+
         return true;
     }
 
     /**
-     * Create a new user from the given ldap credentials and login credentials
+     * Create a new user from the given ldap credentials and login credentials.
+     *
      * @throws LoginAttemptEmailNeededException
      * @throws LoginAttemptException
      * @throws UserRegistrationException
@@ -109,12 +119,15 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
         }
 
         $details = [
-            'name' => $ldapUserDetails['name'],
-            'email' => $ldapUserDetails['email'] ?: $credentials['email'],
+            'name'             => $ldapUserDetails['name'],
+            'email'            => $ldapUserDetails['email'] ?: $credentials['email'],
             'external_auth_id' => $ldapUserDetails['uid'],
-            'password' => Str::random(32),
+            'password'         => Str::random(32),
         ];
 
-        return $this->registrationService->registerUser($details, null, false);
+        $user = $this->registrationService->registerUser($details, null, false);
+        $this->ldapService->saveAndAttachAvatar($user, $ldapUserDetails);
+
+        return $user;
     }
 }
index 352231df54a8e3633a68fbcacb52fc07c332f92f..4bf6db474d527d92de5135b7b924aeee969fd73e 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 /**
  * Class Ldap
@@ -7,26 +9,23 @@
  */
 class Ldap
 {
-
     /**
-     * Connect to a LDAP server.
-     * @param string $hostName
-     * @param int    $port
+     * Connect to an LDAP server.
+     *
      * @return resource
      */
-    public function connect($hostName, $port)
+    public function connect(string $hostName, int $port)
     {
         return ldap_connect($hostName, $port);
     }
 
     /**
      * Set the value of a LDAP option for the given connection.
+     *
      * @param resource $ldapConnection
-     * @param int $option
-     * @param mixed $value
-     * @return bool
+     * @param mixed    $value
      */
-    public function setOption($ldapConnection, $option, $value)
+    public function setOption($ldapConnection, int $option, $value): bool
     {
         return ldap_set_option($ldapConnection, $option, $value);
     }
@@ -41,21 +40,22 @@ class Ldap
 
     /**
      * Set the version number for the given ldap connection.
-     * @param $ldapConnection
-     * @param $version
-     * @return bool
+     *
+     * @param resource $ldapConnection
      */
-    public function setVersion($ldapConnection, $version)
+    public function setVersion($ldapConnection, int $version): bool
     {
         return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
     }
 
     /**
      * Search LDAP tree using the provided filter.
+     *
      * @param resource   $ldapConnection
      * @param string     $baseDn
      * @param string     $filter
      * @param array|null $attributes
+     *
      * @return resource
      */
     public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
@@ -65,8 +65,10 @@ class Ldap
 
     /**
      * Get entries from an ldap search result.
+     *
      * @param resource $ldapConnection
      * @param resource $ldapSearchResult
+     *
      * @return array
      */
     public function getEntries($ldapConnection, $ldapSearchResult)
@@ -76,23 +78,28 @@ class Ldap
 
     /**
      * Search and get entries immediately.
+     *
      * @param resource   $ldapConnection
      * @param string     $baseDn
      * @param string     $filter
      * @param array|null $attributes
+     *
      * @return resource
      */
     public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
     {
         $search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
+
         return $this->getEntries($ldapConnection, $search);
     }
 
     /**
      * Bind to LDAP directory.
+     *
      * @param resource $ldapConnection
      * @param string   $bindRdn
      * @param string   $bindPassword
+     *
      * @return bool
      */
     public function bind($ldapConnection, $bindRdn = null, $bindPassword = null)
@@ -102,8 +109,10 @@ class Ldap
 
     /**
      * Explode a LDAP dn string into an array of components.
+     *
      * @param string $dn
-     * @param int $withAttrib
+     * @param int    $withAttrib
+     *
      * @return array
      */
     public function explodeDn(string $dn, int $withAttrib)
@@ -113,12 +122,14 @@ class Ldap
 
     /**
      * Escape a string for use in an LDAP filter.
+     *
      * @param string $value
      * @param string $ignore
-     * @param int $flags
+     * @param int    $flags
+     *
      * @return string
      */
-    public function escape(string $value, string $ignore = "", int $flags = 0)
+    public function escape(string $value, string $ignore = '', int $flags = 0)
     {
         return ldap_escape($value, $ignore, $flags);
     }
index a438c098490586f44f06b607ff15892e726b60d9..e529b80fdc5129ad326e82567ebb0b0a44a38404 100644 (file)
@@ -1,43 +1,50 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\User;
 use BookStack\Exceptions\JsonDebugException;
 use BookStack\Exceptions\LdapException;
+use BookStack\Uploads\UserAvatars;
 use ErrorException;
+use Illuminate\Support\Facades\Log;
 
 /**
  * Class LdapService
  * Handles any app-specific LDAP tasks.
  */
-class LdapService extends ExternalAuthService
+class LdapService
 {
-
     protected $ldap;
+    protected $groupSyncService;
     protected $ldapConnection;
+    protected $userAvatars;
     protected $config;
     protected $enabled;
 
     /**
      * LdapService constructor.
      */
-    public function __construct(Ldap $ldap)
+    public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
     {
         $this->ldap = $ldap;
+        $this->userAvatars = $userAvatars;
+        $this->groupSyncService = $groupSyncService;
         $this->config = config('services.ldap');
         $this->enabled = config('auth.method') === 'ldap';
     }
 
     /**
      * Check if groups should be synced.
-     * @return bool
      */
-    public function shouldSyncGroups()
+    public function shouldSyncGroups(): bool
     {
         return $this->enabled && $this->config['user_to_groups'] !== false;
     }
 
     /**
      * Search for attributes for a specific user on the ldap.
+     *
      * @throws LdapException
      */
     private function getUserWithAttributes(string $userName, array $attributes): ?array
@@ -69,6 +76,7 @@ class LdapService extends ExternalAuthService
     /**
      * Get the details of a user from LDAP using the given username.
      * User found via configurable user filter.
+     *
      * @throws LdapException
      */
     public function getUserDetails(string $userName): ?array
@@ -76,24 +84,28 @@ class LdapService extends ExternalAuthService
         $idAttr = $this->config['id_attribute'];
         $emailAttr = $this->config['email_attribute'];
         $displayNameAttr = $this->config['display_name_attribute'];
+        $thumbnailAttr = $this->config['thumbnail_attribute'];
 
-        $user = $this->getUserWithAttributes($userName, ['cn', 'dn', $idAttr, $emailAttr, $displayNameAttr]);
+        $user = $this->getUserWithAttributes($userName, array_filter([
+            'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
+        ]));
 
-        if ($user === null) {
+        if (is_null($user)) {
             return null;
         }
 
         $userCn = $this->getUserResponseProperty($user, 'cn', null);
         $formatted = [
-            'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
-            'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
-            'dn' => $user['dn'],
+            'uid'   => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
+            'name'  => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
+            'dn'    => $user['dn'],
             'email' => $this->getUserResponseProperty($user, $emailAttr, null),
+            'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
         ];
 
         if ($this->config['dump_user_details']) {
             throw new JsonDebugException([
-                'details_from_ldap' => $user,
+                'details_from_ldap'        => $user,
                 'details_bookstack_parsed' => $formatted,
             ]);
         }
@@ -129,6 +141,7 @@ class LdapService extends ExternalAuthService
 
     /**
      * Check if the given credentials are valid for the given user.
+     *
      * @throws LdapException
      */
     public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
@@ -138,6 +151,7 @@ class LdapService extends ExternalAuthService
         }
 
         $ldapConnection = $this->getConnection();
+
         try {
             $ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
         } catch (ErrorException $e) {
@@ -150,7 +164,9 @@ class LdapService extends ExternalAuthService
     /**
      * Bind the system user to the LDAP connection using the given credentials
      * otherwise anonymous access is attempted.
-     * @param $connection
+     *
+     * @param resource $connection
+     *
      * @throws LdapException
      */
     protected function bindSystemUser($connection)
@@ -173,8 +189,10 @@ class LdapService extends ExternalAuthService
     /**
      * Get the connection to the LDAP server.
      * Creates a new connection if one does not exist.
-     * @return resource
+     *
      * @throws LdapException
+     *
+     * @return resource
      */
     protected function getConnection()
     {
@@ -214,6 +232,7 @@ class LdapService extends ExternalAuthService
         }
 
         $this->ldapConnection = $ldapConnection;
+
         return $this->ldapConnection;
     }
 
@@ -233,6 +252,7 @@ class LdapService extends ExternalAuthService
         // Otherwise, extract the port out
         $hostName = $serverNameParts[0];
         $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
+
         return ['host' => $hostName, 'port' => $ldapPort];
     }
 
@@ -246,11 +266,13 @@ class LdapService extends ExternalAuthService
             $newKey = '${' . $key . '}';
             $newAttrs[$newKey] = $this->ldap->escape($attrText);
         }
+
         return strtr($filterString, $newAttrs);
     }
 
     /**
      * Get the groups a user is a part of on ldap.
+     *
      * @throws LdapException
      */
     public function getUserGroups(string $userName): array
@@ -263,12 +285,13 @@ class LdapService extends ExternalAuthService
         }
 
         $userGroups = $this->groupFilter($user);
-        $userGroups = $this->getGroupsRecursive($userGroups, []);
-        return $userGroups;
+
+        return $this->getGroupsRecursive($userGroups, []);
     }
 
     /**
      * Get the parent groups of an array of groups.
+     *
      * @throws LdapException
      */
     private function getGroupsRecursive(array $groupsArray, array $checked): array
@@ -295,6 +318,7 @@ class LdapService extends ExternalAuthService
 
     /**
      * Get the parent groups of a single group.
+     *
      * @throws LdapException
      */
     private function getGroupGroups(string $groupName): array
@@ -328,7 +352,7 @@ class LdapService extends ExternalAuthService
         $count = 0;
 
         if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
-            $count = (int)$userGroupSearchResponse[$groupsAttr]['count'];
+            $count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
         }
 
         for ($i = 0; $i < $count; $i++) {
@@ -343,11 +367,30 @@ class LdapService extends ExternalAuthService
 
     /**
      * Sync the LDAP groups to the user roles for the current user.
+     *
      * @throws LdapException
      */
     public function syncGroups(User $user, string $username)
     {
         $userLdapGroups = $this->getUserGroups($username);
-        $this->syncWithGroups($user, $userLdapGroups);
+        $this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
+    }
+
+    /**
+     * Save and attach an avatar image, if found in the ldap details, and attach
+     * to the given user model.
+     */
+    public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
+    {
+        if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
+            return;
+        }
+
+        try {
+            $imageData = $ldapUserDetails['avatar'];
+            $this->userAvatars->assignToUserFromExistingData($user, $imageData, 'jpg');
+        } catch (\Exception $exception) {
+            Log::info("Failed to use avatar image from LDAP data for user id {$user->id}");
+        }
     }
 }
diff --git a/app/Auth/Access/LoginService.php b/app/Auth/Access/LoginService.php
new file mode 100644 (file)
index 0000000..f415704
--- /dev/null
@@ -0,0 +1,164 @@
+<?php
+
+namespace BookStack\Auth\Access;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\Mfa\MfaSession;
+use BookStack\Auth\User;
+use BookStack\Exceptions\StoppedAuthenticationException;
+use BookStack\Facades\Activity;
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
+use Exception;
+
+class LoginService
+{
+    protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
+
+    protected $mfaSession;
+    protected $emailConfirmationService;
+
+    public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
+    {
+        $this->mfaSession = $mfaSession;
+        $this->emailConfirmationService = $emailConfirmationService;
+    }
+
+    /**
+     * Log the given user into the system.
+     * Will start a login of the given user but will prevent if there's
+     * a reason to (MFA or Unconfirmed Email).
+     * Returns a boolean to indicate the current login result.
+     *
+     * @throws StoppedAuthenticationException
+     */
+    public function login(User $user, string $method, bool $remember = false): void
+    {
+        if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
+            $this->setLastLoginAttemptedForUser($user, $method, $remember);
+
+            throw new StoppedAuthenticationException($user, $this);
+        }
+
+        $this->clearLastLoginAttempted();
+        auth()->login($user, $remember);
+        Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}");
+        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')) {
+            $guards = ['standard', 'ldap', 'saml2', 'oidc'];
+            foreach ($guards as $guard) {
+                auth($guard)->login($user);
+            }
+        }
+    }
+
+    /**
+     * Reattempt a system login after a previous stopped attempt.
+     *
+     * @throws Exception
+     */
+    public function reattemptLoginFor(User $user)
+    {
+        if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
+            throw new Exception('Login reattempt user does align with current session state');
+        }
+
+        $lastLoginDetails = $this->getLastLoginAttemptDetails();
+        $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
+    }
+
+    /**
+     * Get the last user that was attempted to be logged in.
+     * Only exists if the last login attempt had correct credentials
+     * but had been prevented by a secondary factor.
+     */
+    public function getLastLoginAttemptUser(): ?User
+    {
+        $id = $this->getLastLoginAttemptDetails()['user_id'];
+
+        return User::query()->where('id', '=', $id)->first();
+    }
+
+    /**
+     * Get the details of the last login attempt.
+     * Checks upon a ttl of about 1 hour since that last attempted login.
+     *
+     * @return array{user_id: ?string, method: ?string, remember: bool}
+     */
+    protected function getLastLoginAttemptDetails(): array
+    {
+        $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
+        if (!$value) {
+            return ['user_id' => null, 'method' => null];
+        }
+
+        [$id, $method, $remember, $time] = explode(':', $value);
+        $hourAgo = time() - (60 * 60);
+        if ($time < $hourAgo) {
+            $this->clearLastLoginAttempted();
+
+            return ['user_id' => null, 'method' => null];
+        }
+
+        return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
+    }
+
+    /**
+     * 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.
+     */
+    protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
+    {
+        session()->put(
+            self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
+            implode(':', [$user->id, $method, $remember, time()])
+        );
+    }
+
+    /**
+     * Clear the last login attempted session value.
+     */
+    protected function clearLastLoginAttempted(): void
+    {
+        session()->remove(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
+    }
+
+    /**
+     * Check if MFA verification is needed.
+     */
+    public function needsMfaVerification(User $user): bool
+    {
+        return !$this->mfaSession->isVerifiedForUser($user) && $this->mfaSession->isRequiredForUser($user);
+    }
+
+    /**
+     * Check if the given user is awaiting email confirmation.
+     */
+    public function awaitingEmailConfirmation(User $user): bool
+    {
+        return $this->emailConfirmationService->confirmationRequired() && !$user->email_confirmed;
+    }
+
+    /**
+     * Attempt the login of a user using the given credentials.
+     * Meant to mirror Laravel's default guard 'attempt' method
+     * but in a manner that always routes through our login system.
+     * May interrupt the flow if extra authentication requirements are imposed.
+     *
+     * @throws StoppedAuthenticationException
+     */
+    public function attempt(array $credentials, string $method, bool $remember = false): bool
+    {
+        $result = auth()->attempt($credentials, $remember);
+        if ($result) {
+            $user = auth()->user();
+            auth()->logout();
+            $this->login($user, $method, $remember);
+        }
+
+        return $result;
+    }
+}
diff --git a/app/Auth/Access/Mfa/BackupCodeService.php b/app/Auth/Access/Mfa/BackupCodeService.php
new file mode 100644 (file)
index 0000000..d58d28a
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+namespace BookStack\Auth\Access\Mfa;
+
+use Illuminate\Support\Str;
+
+class BackupCodeService
+{
+    /**
+     * Generate a new set of 16 backup codes.
+     */
+    public function generateNewSet(): array
+    {
+        $codes = [];
+        while (count($codes) < 16) {
+            $code = Str::random(5) . '-' . Str::random(5);
+            if (!in_array($code, $codes)) {
+                $codes[] = strtolower($code);
+            }
+        }
+
+        return $codes;
+    }
+
+    /**
+     * Check if the given code matches one of the available options.
+     */
+    public function inputCodeExistsInSet(string $code, string $codeSet): bool
+    {
+        $cleanCode = $this->cleanInputCode($code);
+        $codes = json_decode($codeSet);
+
+        return in_array($cleanCode, $codes);
+    }
+
+    /**
+     * Remove the given input code from the given available options.
+     * Will return a JSON string containing the codes.
+     */
+    public function removeInputCodeFromSet(string $code, string $codeSet): string
+    {
+        $cleanCode = $this->cleanInputCode($code);
+        $codes = json_decode($codeSet);
+        $pos = array_search($cleanCode, $codes, true);
+        array_splice($codes, $pos, 1);
+
+        return json_encode($codes);
+    }
+
+    /**
+     * Count the number of codes in the given set.
+     */
+    public function countCodesInSet(string $codeSet): int
+    {
+        return count(json_decode($codeSet));
+    }
+
+    protected function cleanInputCode(string $code): string
+    {
+        return strtolower(str_replace(' ', '-', trim($code)));
+    }
+}
diff --git a/app/Auth/Access/Mfa/MfaSession.php b/app/Auth/Access/Mfa/MfaSession.php
new file mode 100644 (file)
index 0000000..72163b5
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace BookStack\Auth\Access\Mfa;
+
+use BookStack\Auth\User;
+
+class MfaSession
+{
+    /**
+     * Check if MFA is required for the given user.
+     */
+    public function isRequiredForUser(User $user): bool
+    {
+        // TODO - Test both these cases
+        return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
+    }
+
+    /**
+     * Check if the given user is pending MFA setup.
+     * (MFA required but not yet configured).
+     */
+    public function isPendingMfaSetup(User $user): bool
+    {
+        return $this->isRequiredForUser($user) && !$user->mfaValues()->exists();
+    }
+
+    /**
+     * Check if a role of the given user enforces MFA.
+     */
+    protected function userRoleEnforcesMfa(User $user): bool
+    {
+        return $user->roles()
+            ->where('mfa_enforced', '=', true)
+            ->exists();
+    }
+
+    /**
+     * Check if the current MFA session has already been verified for the given user.
+     */
+    public function isVerifiedForUser(User $user): bool
+    {
+        return session()->get($this->getMfaVerifiedSessionKey($user)) === 'true';
+    }
+
+    /**
+     * Mark the current session as MFA-verified.
+     */
+    public function markVerifiedForUser(User $user): void
+    {
+        session()->put($this->getMfaVerifiedSessionKey($user), 'true');
+    }
+
+    /**
+     * Get the session key in which the MFA verification status is stored.
+     */
+    protected function getMfaVerifiedSessionKey(User $user): string
+    {
+        return 'mfa-verification-passed:' . $user->id;
+    }
+}
diff --git a/app/Auth/Access/Mfa/MfaValue.php b/app/Auth/Access/Mfa/MfaValue.php
new file mode 100644 (file)
index 0000000..8f07c66
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+
+namespace BookStack\Auth\Access\Mfa;
+
+use BookStack\Auth\User;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int    $id
+ * @property int    $user_id
+ * @property string $method
+ * @property string $value
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ */
+class MfaValue extends Model
+{
+    protected static $unguarded = true;
+
+    const METHOD_TOTP = 'totp';
+    const METHOD_BACKUP_CODES = 'backup_codes';
+
+    /**
+     * Get all the MFA methods available.
+     */
+    public static function allMethods(): array
+    {
+        return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
+    }
+
+    /**
+     * Upsert a new MFA value for the given user and method
+     * using the provided value.
+     */
+    public static function upsertWithValue(User $user, string $method, string $value): void
+    {
+        /** @var MfaValue $mfaVal */
+        $mfaVal = static::query()->firstOrNew([
+            'user_id' => $user->id,
+            'method'  => $method,
+        ]);
+        $mfaVal->setValue($value);
+        $mfaVal->save();
+    }
+
+    /**
+     * Easily get the decrypted MFA value for the given user and method.
+     */
+    public static function getValueForUser(User $user, string $method): ?string
+    {
+        /** @var MfaValue $mfaVal */
+        $mfaVal = static::query()
+            ->where('user_id', '=', $user->id)
+            ->where('method', '=', $method)
+            ->first();
+
+        return $mfaVal ? $mfaVal->getValue() : null;
+    }
+
+    /**
+     * Decrypt the value attribute upon access.
+     */
+    protected function getValue(): string
+    {
+        return decrypt($this->value);
+    }
+
+    /**
+     * Encrypt the value attribute upon access.
+     */
+    protected function setValue($value): void
+    {
+        $this->value = encrypt($value);
+    }
+}
diff --git a/app/Auth/Access/Mfa/TotpService.php b/app/Auth/Access/Mfa/TotpService.php
new file mode 100644 (file)
index 0000000..e73c549
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+namespace BookStack\Auth\Access\Mfa;
+
+use BaconQrCode\Renderer\Color\Rgb;
+use BaconQrCode\Renderer\Image\SvgImageBackEnd;
+use BaconQrCode\Renderer\ImageRenderer;
+use BaconQrCode\Renderer\RendererStyle\Fill;
+use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Writer;
+use BookStack\Auth\User;
+use PragmaRX\Google2FA\Google2FA;
+use PragmaRX\Google2FA\Support\Constants;
+
+class TotpService
+{
+    protected $google2fa;
+
+    public function __construct(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
+        // the code causing a confusing UX.
+        $this->google2fa->setAlgorithm(Constants::SHA1);
+    }
+
+    /**
+     * Generate a new totp secret key.
+     */
+    public function generateSecret(): string
+    {
+        /** @noinspection PhpUnhandledExceptionInspection */
+        return $this->google2fa->generateSecretKey();
+    }
+
+    /**
+     * Generate a TOTP URL from secret key.
+     */
+    public function generateUrl(string $secret, User $user): string
+    {
+        return $this->google2fa->getQRCodeUrl(
+            setting('app-name'),
+            $user->email,
+            $secret
+        );
+    }
+
+    /**
+     * Generate a QR code to display a TOTP URL.
+     */
+    public function generateQrCodeSvg(string $url): string
+    {
+        $color = Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(32, 110, 167));
+
+        return (new Writer(
+            new ImageRenderer(
+                new RendererStyle(192, 4, null, null, $color),
+                new SvgImageBackEnd()
+            )
+        ))->writeString($url);
+    }
+
+    /**
+     * Verify that the user provided code is valid for the secret.
+     * The secret must be known, not user-provided.
+     */
+    public function verifyCode(string $code, string $secret): bool
+    {
+        /** @noinspection PhpUnhandledExceptionInspection */
+        return $this->google2fa->verifyKey($secret, $code);
+    }
+}
diff --git a/app/Auth/Access/Mfa/TotpValidationRule.php b/app/Auth/Access/Mfa/TotpValidationRule.php
new file mode 100644 (file)
index 0000000..22cb7da
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace BookStack\Auth\Access\Mfa;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class TotpValidationRule implements Rule
+{
+    protected $secret;
+    protected $totpService;
+
+    /**
+     * Create a new rule instance.
+     * Takes the TOTP secret that must be system provided, not user provided.
+     */
+    public function __construct(string $secret)
+    {
+        $this->secret = $secret;
+        $this->totpService = app()->make(TotpService::class);
+    }
+
+    /**
+     * Determine if the validation rule passes.
+     */
+    public function passes($attribute, $value)
+    {
+        return $this->totpService->verifyCode($value, $this->secret);
+    }
+
+    /**
+     * Get the validation error message.
+     */
+    public function message()
+    {
+        return trans('validation.totp');
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcAccessToken.php b/app/Auth/Access/Oidc/OidcAccessToken.php
new file mode 100644 (file)
index 0000000..520966f
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use InvalidArgumentException;
+use League\OAuth2\Client\Token\AccessToken;
+
+class OidcAccessToken extends AccessToken
+{
+    /**
+     * Constructs an access token.
+     *
+     * @param array $options An array of options returned by the service provider
+     *                       in the access token request. The `access_token` option is required.
+     *
+     * @throws InvalidArgumentException if `access_token` is not provided in `$options`.
+     */
+    public function __construct(array $options = [])
+    {
+        parent::__construct($options);
+        $this->validate($options);
+    }
+
+    /**
+     * Validate this access token response for OIDC.
+     * As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
+     */
+    private function validate(array $options): void
+    {
+        // access_token: REQUIRED. Access Token for the UserInfo Endpoint.
+        // Performed on the extended class
+
+        // token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0
+        // Bearer Token Usage [RFC6750], for Clients using this subset.
+        // Note that the token_type value is case-insensitive.
+        if (strtolower(($options['token_type'] ?? '')) !== 'bearer') {
+            throw new InvalidArgumentException('The response token type MUST be "Bearer"');
+        }
+
+        // id_token: REQUIRED. ID Token.
+        if (empty($options['id_token'])) {
+            throw new InvalidArgumentException('An "id_token" property must be provided');
+        }
+    }
+
+    /**
+     * Get the id token value from this access token response.
+     */
+    public function getIdToken(): string
+    {
+        return $this->getValues()['id_token'];
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcIdToken.php b/app/Auth/Access/Oidc/OidcIdToken.php
new file mode 100644 (file)
index 0000000..c955c3b
--- /dev/null
@@ -0,0 +1,238 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcIdToken
+{
+    /**
+     * @var array
+     */
+    protected $header;
+
+    /**
+     * @var array
+     */
+    protected $payload;
+
+    /**
+     * @var string
+     */
+    protected $signature;
+
+    /**
+     * @var array[]|string[]
+     */
+    protected $keys;
+
+    /**
+     * @var string
+     */
+    protected $issuer;
+
+    /**
+     * @var array
+     */
+    protected $tokenParts = [];
+
+    public function __construct(string $token, string $issuer, array $keys)
+    {
+        $this->keys = $keys;
+        $this->issuer = $issuer;
+        $this->parse($token);
+    }
+
+    /**
+     * Parse the token content into its components.
+     */
+    protected function parse(string $token): void
+    {
+        $this->tokenParts = explode('.', $token);
+        $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
+        $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
+        $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
+    }
+
+    /**
+     * Parse a Base64-JSON encoded token part.
+     * Returns the data as a key-value array or empty array upon error.
+     */
+    protected function parseEncodedTokenPart(string $part): array
+    {
+        $json = $this->base64UrlDecode($part) ?: '{}';
+        $decoded = json_decode($json, true);
+
+        return is_array($decoded) ? $decoded : [];
+    }
+
+    /**
+     * Base64URL decode. Needs some character conversions to be compatible
+     * with PHP's default base64 handling.
+     */
+    protected function base64UrlDecode(string $encoded): string
+    {
+        return base64_decode(strtr($encoded, '-_', '+/'));
+    }
+
+    /**
+     * Validate all possible parts of the id token.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    public function validate(string $clientId): bool
+    {
+        $this->validateTokenStructure();
+        $this->validateTokenSignature();
+        $this->validateTokenClaims($clientId);
+
+        return true;
+    }
+
+    /**
+     * Fetch a specific claim from this token.
+     * Returns null if it is null or does not exist.
+     *
+     * @return mixed|null
+     */
+    public function getClaim(string $claim)
+    {
+        return $this->payload[$claim] ?? null;
+    }
+
+    /**
+     * Get all returned claims within the token.
+     */
+    public function getAllClaims(): array
+    {
+        return $this->payload;
+    }
+
+    /**
+     * Validate the structure of the given token and ensure we have the required pieces.
+     * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    protected function validateTokenStructure(): void
+    {
+        foreach (['header', 'payload'] as $prop) {
+            if (empty($this->$prop) || !is_array($this->$prop)) {
+                throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
+            }
+        }
+
+        if (empty($this->signature) || !is_string($this->signature)) {
+            throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
+        }
+    }
+
+    /**
+     * Validate the signature of the given token and ensure it validates against the provided key.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    protected function validateTokenSignature(): void
+    {
+        if ($this->header['alg'] !== 'RS256') {
+            throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
+        }
+
+        $parsedKeys = array_map(function ($key) {
+            try {
+                return new OidcJwtSigningKey($key);
+            } catch (OidcInvalidKeyException $e) {
+                throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
+            }
+        }, $this->keys);
+
+        $parsedKeys = array_filter($parsedKeys);
+
+        $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
+        /** @var OidcJwtSigningKey $parsedKey */
+        foreach ($parsedKeys as $parsedKey) {
+            if ($parsedKey->verify($contentToSign, $this->signature)) {
+                return;
+            }
+        }
+
+        throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
+    }
+
+    /**
+     * Validate the claims of the token.
+     * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
+     *
+     * @throws OidcInvalidTokenException
+     */
+    protected function validateTokenClaims(string $clientId): void
+    {
+        // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
+        // MUST exactly match the value of the iss (issuer) Claim.
+        if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
+            throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
+        }
+
+        // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
+        // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
+        // if the ID Token does not list the Client as a valid audience, or if it contains additional
+        // audiences not trusted by the Client.
+        if (empty($this->payload['aud'])) {
+            throw new OidcInvalidTokenException('Missing token audience value');
+        }
+
+        $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
+        if (count($aud) !== 1) {
+            throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
+        }
+
+        if ($aud[0] !== $clientId) {
+            throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
+        }
+
+        // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
+        // NOTE: Addressed by enforcing a count of 1 above.
+
+        // 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
+        // is the Claim Value.
+        if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
+            throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
+        }
+
+        // 5. The current time MUST be before the time represented by the exp Claim
+        // (possibly allowing for some small leeway to account for clock skew).
+        if (empty($this->payload['exp'])) {
+            throw new OidcInvalidTokenException('Missing token expiration time value');
+        }
+
+        $skewSeconds = 120;
+        $now = time();
+        if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
+            throw new OidcInvalidTokenException('Token has expired');
+        }
+
+        // 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
+        // limiting the amount of time that nonces need to be stored to prevent attacks.
+        // The acceptable range is Client specific.
+        if (empty($this->payload['iat'])) {
+            throw new OidcInvalidTokenException('Missing token issued at time value');
+        }
+
+        $dayAgo = time() - 86400;
+        $iat = intval($this->payload['iat']);
+        if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
+            throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
+        }
+
+        // 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
+        // The meaning and processing of acr Claim Values is out of scope for this document.
+        // NOTE: Not used for our case here. acr is not requested.
+
+        // 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
+        // re-authentication if it determines too much time has elapsed since the last End-User authentication.
+        // NOTE: Not used for our case here. A max_age request is not made.
+
+        // Custom: Ensure the "sub" (Subject) Claim exists and has a value.
+        if (empty($this->payload['sub'])) {
+            throw new OidcInvalidTokenException('Missing token subject value');
+        }
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcInvalidKeyException.php b/app/Auth/Access/Oidc/OidcInvalidKeyException.php
new file mode 100644 (file)
index 0000000..1b3310e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcInvalidKeyException extends \Exception
+{
+}
diff --git a/app/Auth/Access/Oidc/OidcInvalidTokenException.php b/app/Auth/Access/Oidc/OidcInvalidTokenException.php
new file mode 100644 (file)
index 0000000..4f47eb0
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use Exception;
+
+class OidcInvalidTokenException extends Exception
+{
+}
diff --git a/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php b/app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php
new file mode 100644 (file)
index 0000000..e2f364e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+class OidcIssuerDiscoveryException extends \Exception
+{
+}
diff --git a/app/Auth/Access/Oidc/OidcJwtSigningKey.php b/app/Auth/Access/Oidc/OidcJwtSigningKey.php
new file mode 100644 (file)
index 0000000..012a6cb
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use phpseclib3\Crypt\Common\PublicKey;
+use phpseclib3\Crypt\PublicKeyLoader;
+use phpseclib3\Crypt\RSA;
+use phpseclib3\Math\BigInteger;
+
+class OidcJwtSigningKey
+{
+    /**
+     * @var PublicKey
+     */
+    protected $key;
+
+    /**
+     * Can be created either from a JWK parameter array or local file path to load a certificate from.
+     * Examples:
+     * 'file:///var/www/cert.pem'
+     * ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
+     *
+     * @param array|string $jwkOrKeyPath
+     *
+     * @throws OidcInvalidKeyException
+     */
+    public function __construct($jwkOrKeyPath)
+    {
+        if (is_array($jwkOrKeyPath)) {
+            $this->loadFromJwkArray($jwkOrKeyPath);
+        } elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
+            $this->loadFromPath($jwkOrKeyPath);
+        } else {
+            throw new OidcInvalidKeyException('Unexpected type of key value provided');
+        }
+    }
+
+    /**
+     * @throws OidcInvalidKeyException
+     */
+    protected function loadFromPath(string $path)
+    {
+        try {
+            $key = PublicKeyLoader::load(
+                file_get_contents($path)
+            );
+        } catch (\Exception $exception) {
+            throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
+        }
+
+        if (!$key instanceof RSA) {
+            throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
+        }
+
+        $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
+    }
+
+    /**
+     * @throws OidcInvalidKeyException
+     */
+    protected function loadFromJwkArray(array $jwk)
+    {
+        // 'alg' is optional for a JWK, but we will still attempt to validate if
+        // it exists otherwise presume it will be compatible.
+        $alg = $jwk['alg'] ?? null;
+        if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
+            throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
+        }
+
+        if (empty($jwk['use'])) {
+            throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
+        }
+
+        if ($jwk['use'] !== 'sig') {
+            throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
+        }
+
+        if (empty($jwk['e'])) {
+            throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected');
+        }
+
+        if (empty($jwk['n'])) {
+            throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
+        }
+
+        $n = strtr($jwk['n'] ?? '', '-_', '+/');
+
+        try {
+            $key = PublicKeyLoader::load([
+                'e' => new BigInteger(base64_decode($jwk['e']), 256),
+                'n' => new BigInteger(base64_decode($n), 256),
+            ]);
+        } catch (\Exception $exception) {
+            throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
+        }
+
+        if (!$key instanceof RSA) {
+            throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
+        }
+
+        $this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
+    }
+
+    /**
+     * Use this key to sign the given content and return the signature.
+     */
+    public function verify(string $content, string $signature): bool
+    {
+        return $this->key->verify($content, $signature);
+    }
+
+    /**
+     * Convert the key to a PEM encoded key string.
+     */
+    public function toPem(): string
+    {
+        return $this->key->toString('PKCS8');
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcOAuthProvider.php b/app/Auth/Access/Oidc/OidcOAuthProvider.php
new file mode 100644 (file)
index 0000000..9b9d052
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use League\OAuth2\Client\Grant\AbstractGrant;
+use League\OAuth2\Client\Provider\AbstractProvider;
+use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
+use League\OAuth2\Client\Provider\GenericResourceOwner;
+use League\OAuth2\Client\Provider\ResourceOwnerInterface;
+use League\OAuth2\Client\Token\AccessToken;
+use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Extended OAuth2Provider for using with OIDC.
+ * Credit to the https://github.com/steverhoades/oauth2-openid-connect-client
+ * project for the idea of extending a League\OAuth2 client for this use-case.
+ */
+class OidcOAuthProvider extends AbstractProvider
+{
+    use BearerAuthorizationTrait;
+
+    /**
+     * @var string
+     */
+    protected $authorizationEndpoint;
+
+    /**
+     * @var string
+     */
+    protected $tokenEndpoint;
+
+    /**
+     * Returns the base URL for authorizing a client.
+     */
+    public function getBaseAuthorizationUrl(): string
+    {
+        return $this->authorizationEndpoint;
+    }
+
+    /**
+     * Returns the base URL for requesting an access token.
+     */
+    public function getBaseAccessTokenUrl(array $params): string
+    {
+        return $this->tokenEndpoint;
+    }
+
+    /**
+     * Returns the URL for requesting the resource owner's details.
+     */
+    public function getResourceOwnerDetailsUrl(AccessToken $token): string
+    {
+        return '';
+    }
+
+    /**
+     * Returns the default scopes used by this provider.
+     *
+     * This should only be the scopes that are required to request the details
+     * of the resource owner, rather than all the available scopes.
+     */
+    protected function getDefaultScopes(): array
+    {
+        return ['openid', 'profile', 'email'];
+    }
+
+    /**
+     * Returns the string that should be used to separate scopes when building
+     * the URL for requesting an access token.
+     */
+    protected function getScopeSeparator(): string
+    {
+        return ' ';
+    }
+
+    /**
+     * Checks a provider response for errors.
+     *
+     * @param ResponseInterface $response
+     * @param array|string      $data     Parsed response data
+     *
+     * @throws IdentityProviderException
+     *
+     * @return void
+     */
+    protected function checkResponse(ResponseInterface $response, $data)
+    {
+        if ($response->getStatusCode() >= 400 || isset($data['error'])) {
+            throw new IdentityProviderException(
+                $data['error'] ?? $response->getReasonPhrase(),
+                $response->getStatusCode(),
+                (string) $response->getBody()
+            );
+        }
+    }
+
+    /**
+     * Generates a resource owner object from a successful resource owner
+     * details request.
+     *
+     * @param array       $response
+     * @param AccessToken $token
+     *
+     * @return ResourceOwnerInterface
+     */
+    protected function createResourceOwner(array $response, AccessToken $token)
+    {
+        return new GenericResourceOwner($response, '');
+    }
+
+    /**
+     * Creates an access token from a response.
+     *
+     * The grant that was used to fetch the response can be used to provide
+     * additional context.
+     *
+     * @param array         $response
+     * @param AbstractGrant $grant
+     *
+     * @return OidcAccessToken
+     */
+    protected function createAccessToken(array $response, AbstractGrant $grant)
+    {
+        return new OidcAccessToken($response);
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcProviderSettings.php b/app/Auth/Access/Oidc/OidcProviderSettings.php
new file mode 100644 (file)
index 0000000..d157057
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use GuzzleHttp\Psr7\Request;
+use Illuminate\Contracts\Cache\Repository;
+use InvalidArgumentException;
+use Psr\Http\Client\ClientExceptionInterface;
+use Psr\Http\Client\ClientInterface;
+
+/**
+ * OpenIdConnectProviderSettings
+ * Acts as a DTO for settings used within the oidc request and token handling.
+ * Performs auto-discovery upon request.
+ */
+class OidcProviderSettings
+{
+    /**
+     * @var string
+     */
+    public $issuer;
+
+    /**
+     * @var string
+     */
+    public $clientId;
+
+    /**
+     * @var string
+     */
+    public $clientSecret;
+
+    /**
+     * @var string
+     */
+    public $redirectUri;
+
+    /**
+     * @var string
+     */
+    public $authorizationEndpoint;
+
+    /**
+     * @var string
+     */
+    public $tokenEndpoint;
+
+    /**
+     * @var string[]|array[]
+     */
+    public $keys = [];
+
+    public function __construct(array $settings)
+    {
+        $this->applySettingsFromArray($settings);
+        $this->validateInitial();
+    }
+
+    /**
+     * Apply an array of settings to populate setting properties within this class.
+     */
+    protected function applySettingsFromArray(array $settingsArray)
+    {
+        foreach ($settingsArray as $key => $value) {
+            if (property_exists($this, $key)) {
+                $this->$key = $value;
+            }
+        }
+    }
+
+    /**
+     * Validate any core, required properties have been set.
+     *
+     * @throws InvalidArgumentException
+     */
+    protected function validateInitial()
+    {
+        $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
+        foreach ($required as $prop) {
+            if (empty($this->$prop)) {
+                throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
+            }
+        }
+
+        if (strpos($this->issuer, 'https://') !== 0) {
+            throw new InvalidArgumentException('Issuer value must start with https://');
+        }
+    }
+
+    /**
+     * Perform a full validation on these settings.
+     *
+     * @throws InvalidArgumentException
+     */
+    public function validate(): void
+    {
+        $this->validateInitial();
+        $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
+        foreach ($required as $prop) {
+            if (empty($this->$prop)) {
+                throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
+            }
+        }
+    }
+
+    /**
+     * Discover and autoload settings from the configured issuer.
+     *
+     * @throws OidcIssuerDiscoveryException
+     */
+    public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
+    {
+        try {
+            $cacheKey = 'oidc-discovery::' . $this->issuer;
+            $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
+                return $this->loadSettingsFromIssuerDiscovery($httpClient);
+            });
+            $this->applySettingsFromArray($discoveredSettings);
+        } catch (ClientExceptionInterface $exception) {
+            throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
+        }
+    }
+
+    /**
+     * @throws OidcIssuerDiscoveryException
+     * @throws ClientExceptionInterface
+     */
+    protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
+    {
+        $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
+        $request = new Request('GET', $issuerUrl);
+        $response = $httpClient->sendRequest($request);
+        $result = json_decode($response->getBody()->getContents(), true);
+
+        if (empty($result) || !is_array($result)) {
+            throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
+        }
+
+        if ($result['issuer'] !== $this->issuer) {
+            throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
+        }
+
+        $discoveredSettings = [];
+
+        if (!empty($result['authorization_endpoint'])) {
+            $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
+        }
+
+        if (!empty($result['token_endpoint'])) {
+            $discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
+        }
+
+        if (!empty($result['jwks_uri'])) {
+            $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
+            $discoveredSettings['keys'] = $this->filterKeys($keys);
+        }
+
+        return $discoveredSettings;
+    }
+
+    /**
+     * Filter the given JWK keys down to just those we support.
+     */
+    protected function filterKeys(array $keys): array
+    {
+        return array_filter($keys, function (array $key) {
+            $alg = $key['alg'] ?? null;
+
+            return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
+        });
+    }
+
+    /**
+     * Return an array of jwks as PHP key=>value arrays.
+     *
+     * @throws ClientExceptionInterface
+     * @throws OidcIssuerDiscoveryException
+     */
+    protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
+    {
+        $request = new Request('GET', $uri);
+        $response = $httpClient->sendRequest($request);
+        $result = json_decode($response->getBody()->getContents(), true);
+
+        if (empty($result) || !is_array($result) || !isset($result['keys'])) {
+            throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
+        }
+
+        return $result['keys'];
+    }
+
+    /**
+     * Get the settings needed by an OAuth provider, as a key=>value array.
+     */
+    public function arrayForProvider(): array
+    {
+        $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
+        $settings = [];
+        foreach ($settingKeys as $setting) {
+            $settings[$setting] = $this->$setting;
+        }
+
+        return $settings;
+    }
+}
diff --git a/app/Auth/Access/Oidc/OidcService.php b/app/Auth/Access/Oidc/OidcService.php
new file mode 100644 (file)
index 0000000..b8e017b
--- /dev/null
@@ -0,0 +1,221 @@
+<?php
+
+namespace BookStack\Auth\Access\Oidc;
+
+use function auth;
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\RegistrationService;
+use BookStack\Auth\User;
+use BookStack\Exceptions\JsonDebugException;
+use BookStack\Exceptions\OpenIdConnectException;
+use BookStack\Exceptions\StoppedAuthenticationException;
+use BookStack\Exceptions\UserRegistrationException;
+use function config;
+use Exception;
+use Illuminate\Support\Facades\Cache;
+use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
+use Psr\Http\Client\ClientExceptionInterface;
+use Psr\Http\Client\ClientInterface as HttpClient;
+use function trans;
+use function url;
+
+/**
+ * Class OpenIdConnectService
+ * Handles any app-specific OIDC tasks.
+ */
+class OidcService
+{
+    protected $registrationService;
+    protected $loginService;
+    protected $httpClient;
+
+    /**
+     * OpenIdService constructor.
+     */
+    public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
+    {
+        $this->registrationService = $registrationService;
+        $this->loginService = $loginService;
+        $this->httpClient = $httpClient;
+    }
+
+    /**
+     * Initiate an authorization flow.
+     *
+     * @return array{url: string, state: string}
+     */
+    public function login(): array
+    {
+        $settings = $this->getProviderSettings();
+        $provider = $this->getProvider($settings);
+
+        return [
+            'url'   => $provider->getAuthorizationUrl(),
+            'state' => $provider->getState(),
+        ];
+    }
+
+    /**
+     * Process the Authorization response from the authorization server and
+     * return the matching, or new if registration active, user matched to
+     * the authorization server.
+     * Returns null if not authenticated.
+     *
+     * @throws Exception
+     * @throws ClientExceptionInterface
+     */
+    public function processAuthorizeResponse(?string $authorizationCode): ?User
+    {
+        $settings = $this->getProviderSettings();
+        $provider = $this->getProvider($settings);
+
+        // Try to exchange authorization code for access token
+        $accessToken = $provider->getAccessToken('authorization_code', [
+            'code' => $authorizationCode,
+        ]);
+
+        return $this->processAccessTokenCallback($accessToken, $settings);
+    }
+
+    /**
+     * @throws OidcIssuerDiscoveryException
+     * @throws ClientExceptionInterface
+     */
+    protected function getProviderSettings(): OidcProviderSettings
+    {
+        $config = $this->config();
+        $settings = new OidcProviderSettings([
+            'issuer'                => $config['issuer'],
+            'clientId'              => $config['client_id'],
+            'clientSecret'          => $config['client_secret'],
+            'redirectUri'           => url('/oidc/callback'),
+            'authorizationEndpoint' => $config['authorization_endpoint'],
+            'tokenEndpoint'         => $config['token_endpoint'],
+        ]);
+
+        // Use keys if configured
+        if (!empty($config['jwt_public_key'])) {
+            $settings->keys = [$config['jwt_public_key']];
+        }
+
+        // Run discovery
+        if ($config['discover'] ?? false) {
+            $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
+        }
+
+        $settings->validate();
+
+        return $settings;
+    }
+
+    /**
+     * Load the underlying OpenID Connect Provider.
+     */
+    protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
+    {
+        return new OidcOAuthProvider($settings->arrayForProvider(), [
+            'httpClient'     => $this->httpClient,
+            'optionProvider' => new HttpBasicAuthOptionProvider(),
+        ]);
+    }
+
+    /**
+     * Calculate the display name.
+     */
+    protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
+    {
+        $displayNameAttr = $this->config()['display_name_claims'];
+
+        $displayName = [];
+        foreach ($displayNameAttr as $dnAttr) {
+            $dnComponent = $token->getClaim($dnAttr) ?? '';
+            if ($dnComponent !== '') {
+                $displayName[] = $dnComponent;
+            }
+        }
+
+        if (count($displayName) == 0) {
+            $displayName[] = $defaultValue;
+        }
+
+        return implode(' ', $displayName);
+    }
+
+    /**
+     * Extract the details of a user from an ID token.
+     *
+     * @return array{name: string, email: string, external_id: string}
+     */
+    protected function getUserDetails(OidcIdToken $token): array
+    {
+        $id = $token->getClaim('sub');
+
+        return [
+            'external_id' => $id,
+            'email'       => $token->getClaim('email'),
+            'name'        => $this->getUserDisplayName($token, $id),
+        ];
+    }
+
+    /**
+     * Processes a received access token for a user. Login the user when
+     * they exist, optionally registering them automatically.
+     *
+     * @throws OpenIdConnectException
+     * @throws JsonDebugException
+     * @throws UserRegistrationException
+     * @throws StoppedAuthenticationException
+     */
+    protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
+    {
+        $idTokenText = $accessToken->getIdToken();
+        $idToken = new OidcIdToken(
+            $idTokenText,
+            $settings->issuer,
+            $settings->keys,
+        );
+
+        if ($this->config()['dump_user_details']) {
+            throw new JsonDebugException($idToken->getAllClaims());
+        }
+
+        try {
+            $idToken->validate($settings->clientId);
+        } catch (OidcInvalidTokenException $exception) {
+            throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
+        }
+
+        $userDetails = $this->getUserDetails($idToken);
+        $isLoggedIn = auth()->check();
+
+        if (empty($userDetails['email'])) {
+            throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
+        }
+
+        if ($isLoggedIn) {
+            throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
+        }
+
+        $user = $this->registrationService->findOrRegister(
+            $userDetails['name'],
+            $userDetails['email'],
+            $userDetails['external_id']
+        );
+
+        if ($user === null) {
+            throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
+        }
+
+        $this->loginService->login($user, 'oidc');
+
+        return $user;
+    }
+
+    /**
+     * Get the OIDC config from the application.
+     */
+    protected function config(): array
+    {
+        return config('oidc');
+    }
+}
index 68b17771d628552fe57a1d40fe7301774d43adb9..dcdb68bd5cd725530ab31cf69c4a6cc382ea39b6 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\SocialAccount;
@@ -9,10 +11,10 @@ use BookStack\Facades\Activity;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
 use Exception;
+use Illuminate\Support\Str;
 
 class RegistrationService
 {
-
     protected $userRepo;
     protected $emailConfirmationService;
 
@@ -27,6 +29,7 @@ class RegistrationService
 
     /**
      * Check whether or not registrations are allowed in the app settings.
+     *
      * @throws UserRegistrationException
      */
     public function ensureRegistrationAllowed()
@@ -44,11 +47,39 @@ class RegistrationService
     {
         $authMethod = config('auth.method');
         $authMethodsWithRegistration = ['standard'];
+
         return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
     }
 
+    /**
+     * Attempt to find a user in the system otherwise register them as a new
+     * user. For use with external auth systems since password is auto-generated.
+     *
+     * @throws UserRegistrationException
+     */
+    public function findOrRegister(string $name, string $email, string $externalId): User
+    {
+        $user = User::query()
+            ->where('external_auth_id', '=', $externalId)
+            ->first();
+
+        if (is_null($user)) {
+            $userData = [
+                'name'             => $name,
+                'email'            => $email,
+                'password'         => Str::random(32),
+                'external_auth_id' => $externalId,
+            ];
+
+            $user = $this->registerUser($userData, null, false);
+        }
+
+        return $user;
+    }
+
     /**
      * The registrations flow for all users.
+     *
      * @throws UserRegistrationException
      */
     public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
@@ -84,6 +115,7 @@ class RegistrationService
                 session()->flash('sent-email-confirmation', true);
             } catch (Exception $e) {
                 $message = trans('auth.email_confirm_send_error');
+
                 throw new UserRegistrationException($message, '/register/confirm');
             }
         }
@@ -94,6 +126,7 @@ class RegistrationService
     /**
      * Ensure that the given email meets any active email domain registration restrictions.
      * Throws if restrictions are active and the email does not match an allowed domain.
+     *
      * @throws UserRegistrationException
      */
     protected function ensureEmailDomainAllowed(string $userEmail): void
@@ -105,9 +138,10 @@ class RegistrationService
         }
 
         $restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
-        $userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, "@"), 1);
+        $userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
         if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
             $redirect = $this->registrationAllowed() ? '/register' : '/login';
+
             throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect);
         }
     }
index 105853997cd4d939c3fefe66678915dea89bdaa9..f5d0cd7ccf57ee61fd863c8a85f5f9144d71db75 100644 (file)
@@ -1,16 +1,15 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
-use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
 use BookStack\Exceptions\JsonDebugException;
 use BookStack\Exceptions\SamlException;
+use BookStack\Exceptions\StoppedAuthenticationException;
 use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Activity;
-use BookStack\Facades\Theme;
-use BookStack\Theming\ThemeEvents;
 use Exception;
-use Illuminate\Support\Str;
 use OneLogin\Saml2\Auth;
+use OneLogin\Saml2\Constants;
 use OneLogin\Saml2\Error;
 use OneLogin\Saml2\IdPMetadataParser;
 use OneLogin\Saml2\ValidationError;
@@ -19,47 +18,62 @@ use OneLogin\Saml2\ValidationError;
  * Class Saml2Service
  * Handles any app-specific SAML tasks.
  */
-class Saml2Service extends ExternalAuthService
+class Saml2Service
 {
     protected $config;
     protected $registrationService;
-    protected $user;
+    protected $loginService;
+    protected $groupSyncService;
 
     /**
      * Saml2Service constructor.
      */
-    public function __construct(RegistrationService $registrationService, User $user)
-    {
+    public function __construct(
+        RegistrationService $registrationService,
+        LoginService $loginService,
+        GroupSyncService $groupSyncService
+    ) {
         $this->config = config('saml2');
         $this->registrationService = $registrationService;
-        $this->user = $user;
+        $this->loginService = $loginService;
+        $this->groupSyncService = $groupSyncService;
     }
 
     /**
      * Initiate a login flow.
+     *
      * @throws Error
      */
     public function login(): array
     {
         $toolKit = $this->getToolkit();
         $returnRoute = url('/saml2/acs');
+
         return [
             'url' => $toolKit->login($returnRoute, [], false, false, true),
-            'id' => $toolKit->getLastRequestID(),
+            'id'  => $toolKit->getLastRequestID(),
         ];
     }
 
     /**
      * Initiate a logout flow.
+     *
      * @throws Error
      */
-    public function logout(): array
+    public function logout(User $user): array
     {
         $toolKit = $this->getToolkit();
         $returnRoute = url('/');
 
         try {
-            $url = $toolKit->logout($returnRoute, [], null, null, true);
+            $url = $toolKit->logout(
+                $returnRoute,
+                [],
+                $user->email,
+                null,
+                true,
+                Constants::NAMEID_EMAIL_ADDRESS
+            );
             $id = $toolKit->getLastRequestID();
         } catch (Error $error) {
             if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
@@ -78,21 +92,25 @@ class Saml2Service extends ExternalAuthService
      * Process the ACS response from the idp and return the
      * matching, or new if registration active, user matched to the idp.
      * Returns null if not authenticated.
+     *
      * @throws Error
      * @throws SamlException
      * @throws ValidationError
      * @throws JsonDebugException
      * @throws UserRegistrationException
      */
-    public function processAcsResponse(?string $requestId): ?User
+    public function processAcsResponse(?string $requestId, string $samlResponse): ?User
     {
+        // The SAML2 toolkit expects the response to be within the $_POST superglobal
+        // so we need to manually put it back there at this point.
+        $_POST['SAMLResponse'] = $samlResponse;
         $toolkit = $this->getToolkit();
         $toolkit->processResponse($requestId);
         $errors = $toolkit->getErrors();
 
         if (!empty($errors)) {
             throw new Error(
-                'Invalid ACS Response: '.implode(', ', $errors)
+                'Invalid ACS Response: ' . implode(', ', $errors)
             );
         }
 
@@ -108,22 +126,29 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Process a response for the single logout service.
+     *
      * @throws Error
      */
     public function processSlsResponse(?string $requestId): ?string
     {
         $toolkit = $this->getToolkit();
-        $redirect = $toolkit->processSLO(true, $requestId, false, null, true);
 
+        // The $retrieveParametersFromServer in the call below will mean the library will take the query
+        // parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING']
+        // value so that the exact encoding format is matched when checking the signature.
+        // This is primarily due to ADFS encoding query params with lowercase percent encoding while
+        // PHP (And most other sensible providers) standardise on uppercase.
+        $redirect = $toolkit->processSLO(true, $requestId, true, null, true);
         $errors = $toolkit->getErrors();
 
         if (!empty($errors)) {
             throw new Error(
-                'Invalid SLS Response: '.implode(', ', $errors)
+                'Invalid SLS Response: ' . implode(', ', $errors)
             );
         }
 
         $this->actionLogout();
+
         return $redirect;
     }
 
@@ -138,6 +163,7 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Get the metadata for this service provider.
+     *
      * @throws Error
      */
     public function metadata(): string
@@ -149,7 +175,7 @@ class Saml2Service extends ExternalAuthService
 
         if (!empty($errors)) {
             throw new Error(
-                'Invalid SP metadata: '.implode(', ', $errors),
+                'Invalid SP metadata: ' . implode(', ', $errors),
                 Error::METADATA_SP_INVALID
             );
         }
@@ -159,6 +185,7 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Load the underlying Onelogin SAML2 toolkit.
+     *
      * @throws Error
      * @throws Exception
      */
@@ -178,6 +205,7 @@ class Saml2Service extends ExternalAuthService
 
         $spSettings = $this->loadOneloginServiceProviderDetails();
         $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
+
         return new Auth($settings);
     }
 
@@ -187,18 +215,18 @@ class Saml2Service extends ExternalAuthService
     protected function loadOneloginServiceProviderDetails(): array
     {
         $spDetails = [
-            'entityId' => url('/saml2/metadata'),
+            'entityId'                 => url('/saml2/metadata'),
             'assertionConsumerService' => [
                 'url' => url('/saml2/acs'),
             ],
             'singleLogoutService' => [
-                'url' => url('/saml2/sls')
+                'url' => url('/saml2/sls'),
             ],
         ];
 
         return [
             'baseurl' => url('/saml2'),
-            'sp' => $spDetails
+            'sp'      => $spDetails,
         ];
     }
 
@@ -211,7 +239,7 @@ class Saml2Service extends ExternalAuthService
     }
 
     /**
-     * Calculate the display name
+     * Calculate the display name.
      */
     protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
     {
@@ -250,6 +278,8 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Extract the details of a user from a SAML response.
+     *
+     * @return array{external_id: string, name: string, email: string, saml_id: string}
      */
     protected function getUserDetails(string $samlID, $samlAttributes): array
     {
@@ -261,9 +291,9 @@ class Saml2Service extends ExternalAuthService
 
         return [
             'external_id' => $externalId,
-            'name' => $this->getUserDisplayName($samlAttributes, $externalId),
-            'email' => $email,
-            'saml_id' => $samlID,
+            'name'        => $this->getUserDisplayName($samlAttributes, $externalId),
+            'email'       => $email,
+            'saml_id'     => $samlID,
         ];
     }
 
@@ -297,6 +327,7 @@ class Saml2Service extends ExternalAuthService
                 $data = $data[0];
                 break;
         }
+
         return $data;
     }
 
@@ -313,36 +344,14 @@ class Saml2Service extends ExternalAuthService
         return $defaultValue;
     }
 
-    /**
-     * Get the user from the database for the specified details.
-     * @throws UserRegistrationException
-     */
-    protected function getOrRegisterUser(array $userDetails): ?User
-    {
-        $user = $this->user->newQuery()
-          ->where('external_auth_id', '=', $userDetails['external_id'])
-          ->first();
-
-        if (is_null($user)) {
-            $userData = [
-                'name' => $userDetails['name'],
-                'email' => $userDetails['email'],
-                'password' => Str::random(32),
-                'external_auth_id' => $userDetails['external_id'],
-            ];
-
-            $user = $this->registrationService->registerUser($userData, null, false);
-        }
-
-        return $user;
-    }
-
     /**
      * Process the SAML response for a user. Login the user when
      * they exist, optionally registering them automatically.
+     *
      * @throws SamlException
      * @throws JsonDebugException
      * @throws UserRegistrationException
+     * @throws StoppedAuthenticationException
      */
     public function processLoginCallback(string $samlID, array $samlAttributes): User
     {
@@ -351,8 +360,8 @@ class Saml2Service extends ExternalAuthService
 
         if ($this->config['dump_user_details']) {
             throw new JsonDebugException([
-                'id_from_idp' => $samlID,
-                'attrs_from_idp' => $samlAttributes,
+                'id_from_idp'         => $samlID,
+                'attrs_from_idp'      => $samlAttributes,
                 'attrs_after_parsing' => $userDetails,
             ]);
         }
@@ -365,19 +374,23 @@ class Saml2Service extends ExternalAuthService
             throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
         }
 
-        $user = $this->getOrRegisterUser($userDetails);
+        $user = $this->registrationService->findOrRegister(
+            $userDetails['name'],
+            $userDetails['email'],
+            $userDetails['external_id']
+        );
+
         if ($user === null) {
             throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
         }
 
         if ($this->shouldSyncGroups()) {
             $groups = $this->getUserGroups($samlAttributes);
-            $this->syncWithGroups($user, $groups);
+            $this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
         }
 
-        auth()->login($user);
-        Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
-        Theme::dispatch(ThemeEvents::AUTH_LOGIN, 'saml2', $user);
+        $this->loginService->login($user, 'saml2');
+
         return $user;
     }
 }
index 7c8b66ea57ab6ad84b6a7aa839b6d223ee98a039..0df5ceb5ee679fe655c33a1fafd78f86226ab7af 100644 (file)
@@ -1,59 +1,99 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
-use BookStack\Actions\ActivityType;
 use BookStack\Auth\SocialAccount;
 use BookStack\Auth\User;
 use BookStack\Exceptions\SocialDriverNotConfigured;
 use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Activity;
-use BookStack\Facades\Theme;
-use BookStack\Theming\ThemeEvents;
 use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Str;
 use Laravel\Socialite\Contracts\Factory as Socialite;
 use Laravel\Socialite\Contracts\Provider;
 use Laravel\Socialite\Contracts\User as SocialUser;
+use Laravel\Socialite\Two\GoogleProvider;
 use SocialiteProviders\Manager\SocialiteWasCalled;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
 class SocialAuthService
 {
+    /**
+     * The core socialite library used.
+     *
+     * @var Socialite
+     */
     protected $socialite;
-    protected $socialAccount;
 
-    protected $validSocialDrivers = ['google', 'github', 'facebook', 'slack', 'twitter', 'azure', 'okta', 'gitlab', 'twitch', 'discord'];
+    /**
+     * @var LoginService
+     */
+    protected $loginService;
+
+    /**
+     * The default built-in social drivers we support.
+     *
+     * @var string[]
+     */
+    protected $validSocialDrivers = [
+        'google',
+        'github',
+        'facebook',
+        'slack',
+        'twitter',
+        'azure',
+        'okta',
+        'gitlab',
+        'twitch',
+        'discord',
+    ];
+
+    /**
+     * Callbacks to run when configuring a social driver
+     * for an initial redirect action.
+     * Array is keyed by social driver name.
+     * Callbacks are passed an instance of the driver.
+     *
+     * @var array<string, callable>
+     */
+    protected $configureForRedirectCallbacks = [];
 
     /**
      * SocialAuthService constructor.
      */
-    public function __construct(Socialite $socialite)
+    public function __construct(Socialite $socialite, LoginService $loginService)
     {
         $this->socialite = $socialite;
+        $this->loginService = $loginService;
     }
 
     /**
      * Start the social login path.
+     *
      * @throws SocialDriverNotConfigured
      */
     public function startLogIn(string $socialDriver): RedirectResponse
     {
         $driver = $this->validateDriver($socialDriver);
-        return $this->getSocialDriver($driver)->redirect();
+
+        return $this->getDriverForRedirect($driver)->redirect();
     }
 
     /**
-     * Start the social registration process
+     * Start the social registration process.
+     *
      * @throws SocialDriverNotConfigured
      */
     public function startRegister(string $socialDriver): RedirectResponse
     {
         $driver = $this->validateDriver($socialDriver);
-        return $this->getSocialDriver($driver)->redirect();
+
+        return $this->getDriverForRedirect($driver)->redirect();
     }
 
     /**
      * Handle the social registration process on callback.
+     *
      * @throws UserRegistrationException
      */
     public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
@@ -65,6 +105,7 @@ class SocialAuthService
 
         if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) {
             $email = $socialUser->getEmail();
+
             throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
         }
 
@@ -73,16 +114,19 @@ class SocialAuthService
 
     /**
      * Get the social user details via the social driver.
+     *
      * @throws SocialDriverNotConfigured
      */
     public function getSocialUser(string $socialDriver): SocialUser
     {
         $driver = $this->validateDriver($socialDriver);
+
         return $this->socialite->driver($driver)->user();
     }
 
     /**
      * Handle the login process on a oAuth callback.
+     *
      * @throws SocialSignInAccountNotUsed
      */
     public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
@@ -98,9 +142,8 @@ class SocialAuthService
         // When a user is not logged in and a matching SocialAccount exists,
         // Simply log the user into the application.
         if (!$isLoggedIn && $socialAccount !== null) {
-            auth()->login($socialAccount->user);
-            Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
-            Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $socialAccount->user);
+            $this->loginService->login($socialAccount->user, $socialDriver);
+
             return redirect()->intended('/');
         }
 
@@ -110,18 +153,21 @@ class SocialAuthService
             $account = $this->newSocialAccount($socialDriver, $socialUser);
             $currentUser->socialAccounts()->save($account);
             session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
+
             return redirect($currentUser->getEditUrl());
         }
 
         // When a user is logged in and the social account exists and is already linked to the current user.
         if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
             session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
+
             return redirect($currentUser->getEditUrl());
         }
 
         // When a user is logged in, A social account exists but the users do not match.
         if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
             session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
+
             return redirect($currentUser->getEditUrl());
         }
 
@@ -136,6 +182,7 @@ class SocialAuthService
 
     /**
      * Ensure the social driver is correct and supported.
+     *
      * @throws SocialDriverNotConfigured
      */
     protected function validateDriver(string $socialDriver): string
@@ -161,6 +208,7 @@ class SocialAuthService
         $lowerName = strtolower($driver);
         $configPrefix = 'services.' . $lowerName . '.';
         $config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
+
         return !in_array(false, $config) && !in_array(null, $config);
     }
 
@@ -210,9 +258,9 @@ class SocialAuthService
     public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
     {
         return new SocialAccount([
-            'driver' => $socialDriver,
+            'driver'    => $socialDriver,
             'driver_id' => $socialUser->getId(),
-            'avatar' => $socialUser->getAvatar()
+            'avatar'    => $socialUser->getAvatar(),
         ]);
     }
 
@@ -225,17 +273,18 @@ class SocialAuthService
     }
 
     /**
-     * Provide redirect options per service for the Laravel Socialite driver
+     * Provide redirect options per service for the Laravel Socialite driver.
      */
-    public function getSocialDriver(string $driverName): Provider
+    protected function getDriverForRedirect(string $driverName): Provider
     {
         $driver = $this->socialite->driver($driverName);
 
-        if ($driverName === 'google' && config('services.google.select_account')) {
+        if ($driver instanceof GoogleProvider && config('services.google.select_account')) {
             $driver->with(['prompt' => 'select_account']);
         }
-        if ($driverName === 'azure') {
-            $driver->with(['resource' => 'https://graph.windows.net']);
+
+        if (isset($this->configureForRedirectCallbacks[$driverName])) {
+            $this->configureForRedirectCallbacks[$driverName]($driver);
         }
 
         return $driver;
@@ -248,12 +297,19 @@ class SocialAuthService
      * within the `Config/services.php` file.
      * Handler should be a Class@method handler to the SocialiteWasCalled event.
      */
-    public function addSocialDriver(string $driverName, array $config, string $socialiteHandler)
-    {
+    public function addSocialDriver(
+        string $driverName,
+        array $config,
+        string $socialiteHandler,
+        callable $configureForRedirect = null
+    ) {
         $this->validSocialDrivers[] = $driverName;
         config()->set('services.' . $driverName, $config);
         config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
         config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
         Event::listen(SocialiteWasCalled::class, $socialiteHandler);
+        if (!is_null($configureForRedirect)) {
+            $this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
+        }
     }
 }
index 20519fc7d4b98ae9f772a5387deb7fdfe9b84984..d884cd6369317ac5cd832e6dff63ea6636a6cbf9 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\User;
 use BookStack\Notifications\UserInvite;
@@ -11,6 +13,7 @@ class UserInviteService extends UserTokenService
     /**
      * Send an invitation to a user to sign into BookStack
      * Removes existing invitation tokens.
+     *
      * @param User $user
      */
     public function sendInvitation(User $user)
index a1defbf62d439a4bdacdef971271db1dde532afd..ffd828ab5095194b8df7ab638a73980215095c52 100644 (file)
@@ -1,59 +1,56 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\User;
 use BookStack\Exceptions\UserTokenExpiredException;
 use BookStack\Exceptions\UserTokenNotFoundException;
 use Carbon\Carbon;
-use Illuminate\Database\Connection as Database;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 use stdClass;
 
 class UserTokenService
 {
-
     /**
      * Name of table where user tokens are stored.
+     *
      * @var string
      */
     protected $tokenTable = 'user_tokens';
 
     /**
      * Token expiry time in hours.
+     *
      * @var int
      */
     protected $expiryTime = 24;
 
-    protected $db;
-
-    /**
-     * UserTokenService constructor.
-     * @param Database $db
-     */
-    public function __construct(Database $db)
-    {
-        $this->db = $db;
-    }
-
     /**
      * Delete all email confirmations that belong to a user.
+     *
      * @param User $user
+     *
      * @return mixed
      */
     public function deleteByUser(User $user)
     {
-        return $this->db->table($this->tokenTable)
+        return DB::table($this->tokenTable)
             ->where('user_id', '=', $user->id)
             ->delete();
     }
 
     /**
      * Get the user id from a token, while check the token exists and has not expired.
+     *
      * @param string $token
-     * @return int
+     *
      * @throws UserTokenNotFoundException
      * @throws UserTokenExpiredException
+     *
+     * @return int
      */
-    public function checkTokenAndGetUserId(string $token) : int
+    public function checkTokenAndGetUserId(string $token): int
     {
         $entry = $this->getEntryByToken($token);
 
@@ -70,63 +67,74 @@ class UserTokenService
 
     /**
      * Creates a unique token within the email confirmation database.
+     *
      * @return string
      */
-    protected function generateToken() : string
+    protected function generateToken(): string
     {
         $token = Str::random(24);
         while ($this->tokenExists($token)) {
             $token = Str::random(25);
         }
+
         return $token;
     }
 
     /**
      * Generate and store a token for the given user.
+     *
      * @param User $user
+     *
      * @return string
      */
-    protected function createTokenForUser(User $user) : string
+    protected function createTokenForUser(User $user): string
     {
         $token = $this->generateToken();
-        $this->db->table($this->tokenTable)->insert([
-            'user_id' => $user->id,
-            'token' => $token,
+        DB::table($this->tokenTable)->insert([
+            'user_id'    => $user->id,
+            'token'      => $token,
             'created_at' => Carbon::now(),
-            'updated_at' => Carbon::now()
+            'updated_at' => Carbon::now(),
         ]);
+
         return $token;
     }
 
     /**
      * Check if the given token exists.
+     *
      * @param string $token
+     *
      * @return bool
      */
-    protected function tokenExists(string $token) : bool
+    protected function tokenExists(string $token): bool
     {
-        return $this->db->table($this->tokenTable)
+        return DB::table($this->tokenTable)
             ->where('token', '=', $token)->exists();
     }
 
     /**
      * Get a token entry for the given token.
+     *
      * @param string $token
+     *
      * @return object|null
      */
     protected function getEntryByToken(string $token)
     {
-        return $this->db->table($this->tokenTable)
+        return DB::table($this->tokenTable)
             ->where('token', '=', $token)
             ->first();
     }
 
     /**
      * Check if the given token entry has expired.
+     *
      * @param stdClass $tokenEntry
+     *
      * @return bool
      */
-    protected function entryExpired(stdClass $tokenEntry) : bool
+    protected function entryExpired(stdClass $tokenEntry): bool
     {
         return Carbon::now()->subHours($this->expiryTime)
             ->gt(new Carbon($tokenEntry->created_at));
index ef61e03ceb24d27edb289166d1ec744a95034f67..131771a38b7dbee24453440d6b6fd31e72adf594 100644 (file)
@@ -1,15 +1,17 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Model;
 
 class EntityPermission extends Model
 {
-
     protected $fillable = ['role_id', 'action'];
     public $timestamps = false;
 
     /**
      * Get all this restriction's attached entity.
+     *
      * @return \Illuminate\Database\Eloquent\Relations\MorphTo
      */
     public function restrictable()
index 6f7fa582b83a6610b57813bc009fd44582761ab3..e10c560d0fdcfee557690939ad23328093689dcc 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Auth\Role;
 use BookStack\Entities\Models\Entity;
index c5bdc8070cd6190b71e6cb876bf31e2042a8538f..59ff37dc9bcb7ebb9c3b856cc40868e5ccbcae9c 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -48,7 +50,7 @@ class PermissionService
     }
 
     /**
-     * Set the database connection
+     * Set the database connection.
      */
     public function setConnection(Connection $connection)
     {
@@ -56,7 +58,8 @@ class PermissionService
     }
 
     /**
-     * Prepare the local entity cache and ensure it's empty
+     * Prepare the local entity cache and ensure it's empty.
+     *
      * @param Entity[] $entities
      */
     protected function readyEntityCache(array $entities = [])
@@ -73,7 +76,7 @@ class PermissionService
     }
 
     /**
-     * Get a book via ID, Checks local cache
+     * Get a book via ID, Checks local cache.
      */
     protected function getBook(int $bookId): ?Book
     {
@@ -85,7 +88,7 @@ class PermissionService
     }
 
     /**
-     * Get a chapter via ID, Checks local cache
+     * Get a chapter via ID, Checks local cache.
      */
     protected function getChapter(int $chapterId): ?Chapter
     {
@@ -151,12 +154,13 @@ class PermissionService
                 },
                 'pages' => function ($query) {
                     $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
-                }
+                },
             ]);
     }
 
     /**
      * Build joint permissions for the given shelf and role combinations.
+     *
      * @throws Throwable
      */
     protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
@@ -169,6 +173,7 @@ class PermissionService
 
     /**
      * Build joint permissions for the given book and role combinations.
+     *
      * @throws Throwable
      */
     protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
@@ -193,6 +198,7 @@ class PermissionService
 
     /**
      * Rebuild the entity jointPermissions for a particular entity.
+     *
      * @throws Throwable
      */
     public function buildJointPermissionsForEntity(Entity $entity)
@@ -201,6 +207,7 @@ class PermissionService
         if ($entity instanceof Book) {
             $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
             $this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
+
             return;
         }
 
@@ -224,6 +231,7 @@ class PermissionService
 
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
+     *
      * @throws Throwable
      */
     public function buildJointPermissionsForEntities(array $entities)
@@ -263,6 +271,7 @@ class PermissionService
 
     /**
      * Delete all of the entity jointPermissions for a list of entities.
+     *
      * @param Role[] $roles
      */
     protected function deleteManyJointPermissionsForRoles($roles)
@@ -275,7 +284,9 @@ class PermissionService
 
     /**
      * Delete the entity jointPermissions for a particular entity.
+     *
      * @param Entity $entity
+     *
      * @throws Throwable
      */
     public function deleteJointPermissionsForEntity(Entity $entity)
@@ -285,7 +296,9 @@ class PermissionService
 
     /**
      * Delete all of the entity jointPermissions for a list of entities.
+     *
      * @param Entity[] $entities
+     *
      * @throws Throwable
      */
     protected function deleteManyJointPermissionsForEntities(array $entities)
@@ -295,7 +308,6 @@ class PermissionService
         }
 
         $this->db->transaction(function () use ($entities) {
-
             foreach (array_chunk($entities, 1000) as $entityChunk) {
                 $query = $this->db->table('joint_permissions');
                 foreach ($entityChunk as $entity) {
@@ -311,8 +323,10 @@ class PermissionService
 
     /**
      * Create & Save entity jointPermissions for many entities and roles.
+     *
      * @param Entity[] $entities
-     * @param Role[] $roles
+     * @param Role[]   $roles
+     *
      * @throws Throwable
      */
     protected function createManyJointPermissions(array $entities, array $roles)
@@ -363,7 +377,6 @@ class PermissionService
         });
     }
 
-
     /**
      * Get the actions related to an entity.
      */
@@ -376,6 +389,7 @@ class PermissionService
         if ($entity instanceof Book) {
             $baseActions[] = 'chapter-create';
         }
+
         return $baseActions;
     }
 
@@ -397,6 +411,7 @@ class PermissionService
 
         if ($entity->restricted) {
             $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
+
             return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
         }
 
@@ -433,6 +448,7 @@ class PermissionService
     protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
     {
         $key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
+
         return $entityMap[$key] ?? false;
     }
 
@@ -443,18 +459,19 @@ class PermissionService
     protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
     {
         return [
-            'role_id' => $role->getRawAttribute('id'),
-            'entity_id' => $entity->getRawAttribute('id'),
-            'entity_type' => $entity->getMorphClass(),
-            'action' => $action,
-            'has_permission' => $permissionAll,
+            'role_id'            => $role->getRawAttribute('id'),
+            'entity_id'          => $entity->getRawAttribute('id'),
+            'entity_type'        => $entity->getMorphClass(),
+            'action'             => $action,
+            'has_permission'     => $permissionAll,
             'has_permission_own' => $permissionOwn,
-            'owned_by' => $entity->getRawAttribute('owned_by'),
+            'owned_by'           => $entity->getRawAttribute('owned_by'),
         ];
     }
 
     /**
      * Checks if an entity has a restriction set upon it.
+     *
      * @param HasCreatorAndUpdater|HasOwner $ownable
      */
     public function checkOwnableUserAccess(Model $ownable, string $permission): bool
@@ -473,7 +490,8 @@ class PermissionService
             $ownPermission = $user && $user->can($permission . '-own');
             $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
             $isOwner = $user && $user->id === $ownable->$ownerField;
-            return ($allPermission || ($isOwner && $ownPermission));
+
+            return $allPermission || ($isOwner && $ownPermission);
         }
 
         // Handle abnormal create jointPermissions
@@ -483,6 +501,7 @@ class PermissionService
 
         $hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
         $this->clean();
+
         return $hasAccess;
     }
 
@@ -509,6 +528,7 @@ class PermissionService
 
         $hasPermission = $permissionQuery->count() > 0;
         $this->clean();
+
         return $hasPermission;
     }
 
@@ -529,6 +549,7 @@ class PermissionService
         });
 
         $this->clean();
+
         return $q;
     }
 
@@ -539,6 +560,7 @@ class PermissionService
     public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
     {
         $this->clean();
+
         return $query->where(function (Builder $parentQuery) use ($ability) {
             $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
                 $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
@@ -580,25 +602,39 @@ class PermissionService
 
     /**
      * Filter items that have entities set as a polymorphic relation.
+     * For simplicity, this will not return results attached to draft pages.
+     * Draft pages should never really have related items though.
+     *
+     * @param Builder|QueryBuilder $query
      */
-    public function filterRestrictedEntityRelations(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view'): Builder
+    public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
     {
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
-
-        $q = $query->where(function ($query) use ($tableDetails, $action) {
-            $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
-                $permissionQuery->select('id')->from('joint_permissions')
-                    ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                    ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
-                    ->where('action', '=', $action)
-                    ->whereIn('role_id', $this->getCurrentUserRoles())
-                    ->where(function (QueryBuilder $query) {
-                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                    });
-            });
+        $pageMorphClass = (new Page())->getMorphClass();
+
+        $q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
+            /** @var Builder $permissionQuery */
+            $permissionQuery->select(['role_id'])->from('joint_permissions')
+                ->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                ->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
+                ->where('joint_permissions.action', '=', $action)
+                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
+                ->where(function (QueryBuilder $query) {
+                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
+                });
+        })->where(function ($query) use ($tableDetails, $pageMorphClass) {
+            /** @var Builder $query */
+            $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
+                ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
+                    $query->select('id')->from('pages')
+                        ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
+                        ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
+                        ->where('pages.draft', '=', false);
+                });
         });
 
         $this->clean();
+
         return $q;
     }
 
@@ -608,43 +644,60 @@ class PermissionService
      */
     public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
     {
-        $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
-        $morphClass = app($entityClass)->getMorphClass();
-
-        $q = $query->where(function ($query) use ($tableDetails, $morphClass) {
-            $query->where(function ($query) use (&$tableDetails, $morphClass) {
-                $query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
-                    $permissionQuery->select('id')->from('joint_permissions')
-                        ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                        ->where('entity_type', '=', $morphClass)
-                        ->where('action', '=', 'view')
-                        ->whereIn('role_id', $this->getCurrentUserRoles())
-                        ->where(function (QueryBuilder $query) {
-                            $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
-                        });
+        $fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
+        $instance = new $entityClass();
+        $morphClass = $instance->getMorphClass();
+
+        $existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
+            /** @var Builder $permissionQuery */
+            $permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
+                ->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
+                ->where('joint_permissions.entity_type', '=', $morphClass)
+                ->where('joint_permissions.action', '=', 'view')
+                ->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
+                ->where(function (QueryBuilder $query) {
+                    $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
                 });
-            })->orWhere($tableDetails['entityIdColumn'], '=', 0);
+        };
+
+        $q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
+            $query->whereExists($existsQuery)
+                ->orWhere($fullEntityIdColumn, '=', 0);
         });
 
+        if ($instance instanceof Page) {
+            // Prevent visibility of non-owned draft pages
+            $q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
+                $query->select('id')->from('pages')
+                    ->whereColumn('pages.id', '=', $fullEntityIdColumn)
+                    ->where(function (QueryBuilder $query) {
+                        $query->where('pages.draft', '=', false)
+                            ->orWhere('pages.owned_by', '=', $this->currentUser()->id);
+                    });
+            });
+        }
+
         $this->clean();
+
         return $q;
     }
 
     /**
      * Add the query for checking the given user id has permission
      * within the join_permissions table.
+     *
      * @param QueryBuilder|Builder $query
      */
     protected function addJointHasPermissionCheck($query, int $userIdToCheck)
     {
-        $query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
-            $query->where('has_permission_own', '=', true)
-                ->where('owned_by', '=', $userIdToCheck);
+        $query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
+            $query->where('joint_permissions.has_permission_own', '=', true)
+                ->where('joint_permissions.owned_by', '=', $userIdToCheck);
         });
     }
 
     /**
-     * Get the current user
+     * Get the current user.
      */
     private function currentUser(): User
     {
index f54612a4339a3423557a994a3fa876636215799b..988146700f80e1760c6d667ac0fe29dc0de22542 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\Role;
@@ -9,7 +11,6 @@ use Illuminate\Database\Eloquent\Collection;
 
 class PermissionsRepo
 {
-
     protected $permission;
     protected $role;
     protected $permissionService;
@@ -56,12 +57,14 @@ class PermissionsRepo
     public function saveNewRole(array $roleData): Role
     {
         $role = $this->role->newInstance($roleData);
+        $role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
         $role->save();
 
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
         $this->assignRolePermissions($role, $permissions);
         $this->permissionService->buildJointPermissionForRole($role);
         Activity::add(ActivityType::ROLE_CREATE, $role);
+
         return $role;
     }
 
@@ -88,6 +91,7 @@ class PermissionsRepo
         $this->assignRolePermissions($role, $permissions);
 
         $role->fill($roleData);
+        $role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
         $role->save();
         $this->permissionService->buildJointPermissionForRole($role);
         Activity::add(ActivityType::ROLE_UPDATE, $role);
@@ -116,6 +120,7 @@ class PermissionsRepo
      * Check it's not an admin role or set as default before deleting.
      * If an migration Role ID is specified the users assign to the current role
      * will be added to the role of the specified id.
+     *
      * @throws PermissionsException
      * @throws Exception
      */
@@ -127,7 +132,7 @@ class PermissionsRepo
         // Prevent deleting admin role or default registration role.
         if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
             throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
-        } else if ($role->id === intval(setting('registration-role'))) {
+        } elseif ($role->id === intval(setting('registration-role'))) {
             throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
         }
 
index 7f44ff8152b5a23b759b5fce3a83238da01855f9..f34de917c07a425e7bf689c728d2edbe8ed2bfab 100644 (file)
@@ -1,7 +1,10 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Auth\Role;
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
 /**
  * @property int $id
@@ -11,17 +14,15 @@ class RolePermission extends Model
     /**
      * The roles that belong to the permission.
      */
-    public function roles()
+    public function roles(): BelongsToMany
     {
         return $this->belongsToMany(Role::class, 'permission_role', 'permission_id', 'role_id');
     }
 
     /**
      * Get the permission object by name.
-     * @param $name
-     * @return mixed
      */
-    public static function getByName($name)
+    public static function getByName(string $name): ?RolePermission
     {
         return static::where('name', '=', $name)->first();
     }
index 629cd6a955d8abf7961b67aa1c598d1d62d30658..71da88e19b1be5200cb4f41dd6396427efd7c65b 100644 (file)
@@ -1,23 +1,30 @@
-<?php namespace BookStack\Auth;
+<?php
+
+namespace BookStack\Auth;
 
 use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Interfaces\Loggable;
 use BookStack\Model;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 
 /**
- * Class Role
- * @property int $id
- * @property string $display_name
- * @property string $description
- * @property string $external_auth_id
- * @property string $system_name
+ * Class Role.
+ *
+ * @property int        $id
+ * @property string     $display_name
+ * @property string     $description
+ * @property string     $external_auth_id
+ * @property string     $system_name
+ * @property bool       $mfa_enforced
+ * @property Collection $users
  */
 class Role extends Model implements Loggable
 {
+    use HasFactory;
 
     protected $fillable = ['display_name', 'description', 'external_auth_id'];
 
@@ -56,6 +63,7 @@ class Role extends Model implements Loggable
                 return true;
             }
         }
+
         return false;
     }
 
@@ -78,7 +86,7 @@ class Role extends Model implements Loggable
     /**
      * Get the role of the specified display name.
      */
-    public static function getRole(string $displayName): ?Role
+    public static function getRole(string $displayName): ?self
     {
         return static::query()->where('display_name', '=', $displayName)->first();
     }
@@ -86,13 +94,13 @@ class Role extends Model implements Loggable
     /**
      * Get the role object for the specified system role.
      */
-    public static function getSystemRole(string $systemName): ?Role
+    public static function getSystemRole(string $systemName): ?self
     {
         return static::query()->where('system_name', '=', $systemName)->first();
     }
 
     /**
-     * Get all visible roles
+     * Get all visible roles.
      */
     public static function visible(): Collection
     {
@@ -104,11 +112,14 @@ class Role extends Model implements Loggable
      */
     public static function restrictable(): Collection
     {
-        return static::query()->where('system_name', '!=', 'admin')->get();
+        return static::query()
+            ->where('system_name', '!=', 'admin')
+            ->orderBy('display_name', 'asc')
+            ->get();
     }
 
     /**
-     * @inheritdoc
+     * {@inheritdoc}
      */
     public function logDescriptor(): string
     {
index 116cdc8546957a4071ad54bbc52aba5b8b6ede6c..6cf0224a8d362a2ae6c8bf282f615ad1038e96ad 100644 (file)
@@ -1,16 +1,18 @@
-<?php namespace BookStack\Auth;
+<?php
+
+namespace BookStack\Auth;
 
 use BookStack\Interfaces\Loggable;
 use BookStack\Model;
 
 /**
- * Class SocialAccount
+ * Class SocialAccount.
+ *
  * @property string $driver
- * @property User $user
+ * @property User   $user
  */
 class SocialAccount extends Model implements Loggable
 {
-
     protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
 
     public function user()
@@ -19,7 +21,7 @@ class SocialAccount extends Model implements Loggable
     }
 
     /**
-     * @inheritDoc
+     * {@inheritdoc}
      */
     public function logDescriptor(): string
     {
index 9855ab4e7408e86403f4e5c11757afc95af68241..f969b351f4a169bab7144c36a3c05c3a3de6af9b 100644 (file)
@@ -1,6 +1,10 @@
-<?php namespace BookStack\Auth;
+<?php
 
+namespace BookStack\Auth;
+
+use BookStack\Actions\Favourite;
 use BookStack\Api\ApiToken;
+use BookStack\Auth\Access\Mfa\MfaValue;
 use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Interfaces\Loggable;
 use BookStack\Interfaces\Sluggable;
@@ -14,6 +18,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
 use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
 use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -21,32 +26,39 @@ use Illuminate\Notifications\Notifiable;
 use Illuminate\Support\Collection;
 
 /**
- * Class User
- * @property string $id
- * @property string $name
- * @property string $slug
- * @property string $email
- * @property string $password
- * @property Carbon $created_at
- * @property Carbon $updated_at
- * @property bool $email_confirmed
- * @property int $image_id
- * @property string $external_auth_id
- * @property string $system_name
+ * Class User.
+ *
+ * @property int        $id
+ * @property string     $name
+ * @property string     $slug
+ * @property string     $email
+ * @property string     $password
+ * @property Carbon     $created_at
+ * @property Carbon     $updated_at
+ * @property bool       $email_confirmed
+ * @property int        $image_id
+ * @property string     $external_auth_id
+ * @property string     $system_name
  * @property Collection $roles
+ * @property Collection $mfaValues
  */
 class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
 {
-    use Authenticatable, CanResetPassword, Notifiable;
+    use HasFactory;
+    use Authenticatable;
+    use CanResetPassword;
+    use Notifiable;
 
     /**
      * The database table used by the model.
+     *
      * @var string
      */
     protected $table = 'users';
 
     /**
      * The attributes that are mass assignable.
+     *
      * @var array
      */
     protected $fillable = ['name', 'email'];
@@ -55,6 +67,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * The attributes excluded from the model's JSON form.
+     *
      * @var array
      */
     protected $hidden = [
@@ -64,12 +77,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * This holds the user's permissions when loaded.
+     *
      * @var ?Collection
      */
     protected $permissions;
 
     /**
      * This holds the default user when loaded.
+     *
      * @var null|User
      */
     protected static $defaultUser = null;
@@ -77,13 +92,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     /**
      * Returns the default public user.
      */
-    public static function getDefault(): User
+    public static function getDefault(): self
     {
         if (!is_null(static::$defaultUser)) {
             return static::$defaultUser;
         }
-        
+
         static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
+
         return static::$defaultUser;
     }
 
@@ -97,13 +113,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * The roles that belong to the user.
+     *
      * @return BelongsToMany
      */
     public function roles()
     {
         if ($this->id === 0) {
-            return ;
+            return;
         }
+
         return $this->belongsToMany(Role::class);
     }
 
@@ -128,7 +146,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
      */
     public function attachDefaultRole(): void
     {
-        $roleId = setting('registration-role');
+        $roleId = intval(setting('registration-role'));
         if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
             $this->roles()->attach($roleId);
         }
@@ -160,7 +178,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
             ->leftJoin('permission_role', 'ru.role_id', '=', 'permission_role.role_id')
             ->leftJoin('role_permissions', 'permission_role.permission_id', '=', 'role_permissions.id')
             ->where('ru.user_id', '=', $this->id)
-            ->get()
             ->pluck('name');
 
         return $this->permissions;
@@ -193,7 +210,9 @@ 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
      */
     public function hasSocialAccount($socialDriver = false)
@@ -206,7 +225,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * Returns a URL to the user's avatar
+     * Returns a URL to the user's avatar.
      */
     public function getAvatar(int $size = 50): string
     {
@@ -221,6 +240,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
         } catch (Exception $err) {
             $avatar = $default;
         }
+
         return $avatar;
     }
 
@@ -240,6 +260,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
         return $this->hasMany(ApiToken::class);
     }
 
+    /**
+     * Get the favourite instances for this user.
+     */
+    public function favourites(): HasMany
+    {
+        return $this->hasMany(Favourite::class);
+    }
+
+    /**
+     * Get the MFA values belonging to this use.
+     */
+    public function mfaValues(): HasMany
+    {
+        return $this->hasMany(MfaValue::class);
+    }
+
     /**
      * Get the last activity time for this user.
      */
@@ -259,6 +295,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     public function getEditUrl(string $path = ''): string
     {
         $uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
+
         return url(rtrim($uri, '/'));
     }
 
@@ -289,7 +326,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * Send the password reset notification.
-     * @param  string  $token
+     *
+     * @param string $token
+     *
      * @return void
      */
     public function sendPasswordResetNotification($token)
@@ -298,7 +337,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * @inheritdoc
+     * {@inheritdoc}
      */
     public function logDescriptor(): string
     {
@@ -306,11 +345,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * @inheritDoc
+     * {@inheritdoc}
      */
     public function refreshSlug(): string
     {
         $this->slug = app(SlugGenerator::class)->generate($this);
+
         return $this->slug;
     }
 }
index 4444c734c35077a557fcdfe8daf8664b082bfe3d..0dea4172528326eabb7240f3cc327c61a7a6e748 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Auth;
+<?php
+
+namespace BookStack\Auth;
 
-use Activity;
 use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -8,14 +9,12 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\UserUpdateException;
-use BookStack\Uploads\Image;
 use BookStack\Uploads\UserAvatars;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Pagination\LengthAwarePaginator;
-use Images;
-use Log;
+use Illuminate\Support\Facades\Log;
 
 class UserRepo
 {
@@ -73,14 +72,18 @@ class UserRepo
     }
     /**
      * Get all the users with their permissions in a paginated format.
+     * Note: Due to the use of email search this should only be used when
+     * user is assumed to be trusted. (Admin users).
+     * Email search can be abused to extract email addresses.
      */
     public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
     {
         $sort = $sortData['sort'];
 
         $query = User::query()->select(['*'])
-            ->withLastActivityAt()
+            ->scopes(['withLastActivityAt'])
             ->with(['roles', 'avatar'])
+            ->withCount('mfaValues')
             ->orderBy($sort, $sortData['order']);
 
         if ($sortData['search']) {
@@ -94,7 +97,7 @@ class UserRepo
         return $query->paginate($count);
     }
 
-     /**
+    /**
      * Creates a new user and attaches a role to them.
      */
     public function registerNew(array $data, bool $emailConfirmed = false): User
@@ -108,6 +111,7 @@ class UserRepo
 
     /**
      * Assign a user to a system-level role.
+     *
      * @throws NotFoundException
      */
     public function attachSystemRole(User $user, string $systemRoleName)
@@ -138,6 +142,7 @@ class UserRepo
 
     /**
      * Set the assigned user roles via an array of role IDs.
+     *
      * @throws UserUpdateException
      */
     public function setUserRoles(User $user, array $roles)
@@ -153,7 +158,7 @@ class UserRepo
      * Check if the given user is the last admin and their new roles no longer
      * contains the admin role.
      */
-    protected function demotingLastAdmin(User $user, array $newRoles) : bool
+    protected function demotingLastAdmin(User $user, array $newRoles): bool
     {
         if ($this->isOnlyAdmin($user)) {
             $adminRole = Role::getSystemRole('admin');
@@ -171,10 +176,10 @@ class UserRepo
     public function create(array $data, bool $emailConfirmed = false): User
     {
         $details = [
-            'name'     => $data['name'],
-            'email'    => $data['email'],
-            'password' => bcrypt($data['password']),
-            'email_confirmed' => $emailConfirmed,
+            'name'             => $data['name'],
+            'email'            => $data['email'],
+            'password'         => bcrypt($data['password']),
+            'email_confirmed'  => $emailConfirmed,
             'external_auth_id' => $data['external_auth_id'] ?? '',
         ];
 
@@ -188,22 +193,19 @@ class UserRepo
 
     /**
      * Remove the given user from storage, Delete all related content.
+     *
      * @throws Exception
      */
     public function destroy(User $user, ?int $newOwnerId = null)
     {
         $user->socialAccounts()->delete();
         $user->apiTokens()->delete();
+        $user->favourites()->delete();
+        $user->mfaValues()->delete();
         $user->delete();
-        
-        // Delete user profile images
-        $profileImages = Image::query()->where('type', '=', 'user')
-            ->where('uploaded_to', '=', $user->id)
-            ->get();
 
-        foreach ($profileImages as $image) {
-            Images::destroy($image);
-        }
+        // Delete user profile images
+        $this->userAvatar->destroyAllForUser($user);
 
         if (!empty($newOwnerId)) {
             $newOwner = User::query()->find($newOwnerId);
@@ -218,21 +220,13 @@ class UserRepo
      */
     protected function migrateOwnership(User $fromUser, User $toUser)
     {
-        $entities = (new EntityProvider)->all();
+        $entities = (new EntityProvider())->all();
         foreach ($entities as $instance) {
             $instance->newQuery()->where('owned_by', '=', $fromUser->id)
                 ->update(['owned_by' => $toUser->id]);
         }
     }
 
-    /**
-     * Get the latest activity for a user.
-     */
-    public function getActivity(User $user, int $count = 20, int $page = 0): array
-    {
-        return Activity::userActivity($user, $count, $page);
-    }
-
     /**
      * Get the recently created content for this given user.
      */
@@ -259,11 +253,12 @@ class UserRepo
     public function getAssetCounts(User $user): array
     {
         $createdBy = ['created_by' => $user->id];
+
         return [
-            'pages'    =>  Page::visible()->where($createdBy)->count(),
-            'chapters'    =>  Chapter::visible()->where($createdBy)->count(),
-            'books'    =>  Book::visible()->where($createdBy)->count(),
-            'shelves'    =>  Bookshelf::visible()->where($createdBy)->count(),
+            'pages'       => Page::visible()->where($createdBy)->count(),
+            'chapters'    => Chapter::visible()->where($createdBy)->count(),
+            'books'       => Book::visible()->where($createdBy)->count(),
+            'shelves'     => Bookshelf::visible()->where($createdBy)->count(),
         ];
     }
 
index 6afea2dc89d293b7fcf16d4129e3241301072c9a..03f191fee3b18228913cf9387a8d7f3096399a2e 100644 (file)
@@ -18,6 +18,6 @@ return [
     'max_item_count' => env('API_MAX_ITEM_COUNT', 500),
 
     // The number of API requests that can be made per minute by a single user.
-    'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180)
+    'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180),
 
 ];
old mode 100755 (executable)
new mode 100644 (file)
index 065845f..39bfa71
@@ -31,11 +31,19 @@ return [
     // Set to -1 for unlimited recycle bin lifetime.
     'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
 
+    // The limit for all uploaded files, including images and attachments in MB.
+    'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
+
     // Allow <script> tags to entered within page content.
     // <script> tags are escaped by default.
     // Even when overridden the WYSIWYG editor may still escape script content.
     'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
 
+    // Allow server-side fetches to be performed to potentially unknown
+    // and user-provided locations. Primarily used in exports when loading
+    // in externally referenced assets.
+    'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
+
     // Override the default behaviour for allowing crawlers to crawl the instance.
     // May be ignored if view has be overridden or modified.
     // Defaults to null since, if not set, 'app-public' status used instead.
@@ -56,7 +64,7 @@ return [
     'locale' => env('APP_LANG', 'en'),
 
     // Locales available
-    'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'ko', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl',  'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
+    'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl',  'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
 
     //  Application Fallback Locale
     'fallback_locale' => 'en',
@@ -138,57 +146,57 @@ return [
 
     // Class aliases, Registered on application start
     'aliases' => [
-
         // Laravel
-        'App'       => Illuminate\Support\Facades\App::class,
-        'Arr'       => Illuminate\Support\Arr::class,
-        'Artisan'   => Illuminate\Support\Facades\Artisan::class,
-        'Auth'      => Illuminate\Support\Facades\Auth::class,
-        'Blade'     => Illuminate\Support\Facades\Blade::class,
-        'Bus'       => Illuminate\Support\Facades\Bus::class,
-        'Cache'     => Illuminate\Support\Facades\Cache::class,
-        'Config'    => Illuminate\Support\Facades\Config::class,
-        'Cookie'    => Illuminate\Support\Facades\Cookie::class,
-        'Crypt'     => Illuminate\Support\Facades\Crypt::class,
-        'DB'        => Illuminate\Support\Facades\DB::class,
-        'Eloquent'  => Illuminate\Database\Eloquent\Model::class,
-        'Event'     => Illuminate\Support\Facades\Event::class,
-        'File'      => Illuminate\Support\Facades\File::class,
-        'Hash'      => Illuminate\Support\Facades\Hash::class,
-        'Input'     => Illuminate\Support\Facades\Input::class,
-        'Inspiring' => Illuminate\Foundation\Inspiring::class,
-        'Lang'      => Illuminate\Support\Facades\Lang::class,
-        'Log'       => Illuminate\Support\Facades\Log::class,
-        'Mail'      => Illuminate\Support\Facades\Mail::class,
+        'App'          => Illuminate\Support\Facades\App::class,
+        'Arr'          => Illuminate\Support\Arr::class,
+        'Artisan'      => Illuminate\Support\Facades\Artisan::class,
+        'Auth'         => Illuminate\Support\Facades\Auth::class,
+        'Blade'        => Illuminate\Support\Facades\Blade::class,
+        'Bus'          => Illuminate\Support\Facades\Bus::class,
+        'Cache'        => Illuminate\Support\Facades\Cache::class,
+        'Config'       => Illuminate\Support\Facades\Config::class,
+        'Cookie'       => Illuminate\Support\Facades\Cookie::class,
+        'Crypt'        => Illuminate\Support\Facades\Crypt::class,
+        'Date'         => Illuminate\Support\Facades\Date::class,
+        'DB'           => Illuminate\Support\Facades\DB::class,
+        'Eloquent'     => Illuminate\Database\Eloquent\Model::class,
+        'Event'        => Illuminate\Support\Facades\Event::class,
+        'File'         => Illuminate\Support\Facades\File::class,
+        'Gate'         => Illuminate\Support\Facades\Gate::class,
+        'Hash'         => Illuminate\Support\Facades\Hash::class,
+        'Http'         => Illuminate\Support\Facades\Http::class,
+        'Lang'         => Illuminate\Support\Facades\Lang::class,
+        'Log'          => Illuminate\Support\Facades\Log::class,
+        'Mail'         => Illuminate\Support\Facades\Mail::class,
         'Notification' => Illuminate\Support\Facades\Notification::class,
-        'Password'  => Illuminate\Support\Facades\Password::class,
-        'Queue'     => Illuminate\Support\Facades\Queue::class,
-        'Redirect'  => Illuminate\Support\Facades\Redirect::class,
-        'Redis'     => Illuminate\Support\Facades\Redis::class,
-        'Request'   => Illuminate\Support\Facades\Request::class,
-        'Response'  => Illuminate\Support\Facades\Response::class,
-        'Route'     => Illuminate\Support\Facades\Route::class,
-        'Schema'    => Illuminate\Support\Facades\Schema::class,
-        'Session'   => Illuminate\Support\Facades\Session::class,
-        'Storage'   => Illuminate\Support\Facades\Storage::class,
-        'Str'       => Illuminate\Support\Str::class,
-        'URL'       => Illuminate\Support\Facades\URL::class,
-        'Validator' => Illuminate\Support\Facades\Validator::class,
-        'View'      => Illuminate\Support\Facades\View::class,
-        'Socialite' => Laravel\Socialite\Facades\Socialite::class,
+        'Password'     => Illuminate\Support\Facades\Password::class,
+        'Queue'        => Illuminate\Support\Facades\Queue::class,
+        'RateLimiter'  => Illuminate\Support\Facades\RateLimiter::class,
+        'Redirect'     => Illuminate\Support\Facades\Redirect::class,
+        // 'Redis'        => Illuminate\Support\Facades\Redis::class,
+        'Request'      => Illuminate\Support\Facades\Request::class,
+        'Response'     => Illuminate\Support\Facades\Response::class,
+        'Route'        => Illuminate\Support\Facades\Route::class,
+        'Schema'       => Illuminate\Support\Facades\Schema::class,
+        'Session'      => Illuminate\Support\Facades\Session::class,
+        'Storage'      => Illuminate\Support\Facades\Storage::class,
+        'Str'          => Illuminate\Support\Str::class,
+        'URL'          => Illuminate\Support\Facades\URL::class,
+        'Validator'    => Illuminate\Support\Facades\Validator::class,
+        'View'         => Illuminate\Support\Facades\View::class,
+
+        // Laravel Packages
+        'Socialite'    => Laravel\Socialite\Facades\Socialite::class,
 
         // Third Party
         'ImageTool' => Intervention\Image\Facades\Image::class,
-        'DomPDF' => Barryvdh\DomPDF\Facade::class,
+        'DomPDF'    => Barryvdh\DomPDF\Facade::class,
         'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
 
         // Custom BookStack
-        'Activity' => BookStack\Facades\Activity::class,
-        'Views'    => BookStack\Facades\Views::class,
-        'Images'   => BookStack\Facades\Images::class,
+        'Activity'    => BookStack\Facades\Activity::class,
         'Permissions' => BookStack\Facades\Permissions::class,
-        'Theme'    => BookStack\Facades\Theme::class,
-
+        'Theme'       => BookStack\Facades\Theme::class,
     ],
 
     // Proxy configuration
index 51b152ff184805e67e5addd1573a6e2258c51e9d..1e1a9d3507c737cf0ff7284cd52d97325620efac 100644 (file)
 
 return [
 
-    // Method of authentication to use
-    // Options: standard, ldap, saml2
+    // Options: standard, ldap, saml2, oidc
     'method' => env('AUTH_METHOD', 'standard'),
 
     // Authentication Defaults
     // This option controls the default authentication "guard" and password
     // reset options for your application.
     'defaults' => [
-        'guard' => env('AUTH_METHOD', 'standard'),
+        'guard'     => env('AUTH_METHOD', 'standard'),
         'passwords' => 'users',
     ],
 
@@ -26,22 +25,26 @@ return [
     // All authentication drivers have a user provider. This defines how the
     // users are actually retrieved out of your database or other storage
     // mechanisms used by this application to persist your user's data.
-    // Supported drivers: "session", "api-token", "ldap-session"
+    // Supported drivers: "session", "api-token", "ldap-session", "async-external-session"
     'guards' => [
         'standard' => [
-            'driver' => 'session',
+            'driver'   => 'session',
             'provider' => 'users',
         ],
         'ldap' => [
-            'driver' => 'ldap-session',
+            'driver'   => 'ldap-session',
             'provider' => 'external',
         ],
         'saml2' => [
-            'driver' => 'saml2-session',
+            'driver'   => 'async-external-session',
+            'provider' => 'external',
+        ],
+        'oidc' => [
+            'driver'   => 'async-external-session',
             'provider' => 'external',
         ],
         'api' => [
-            'driver' => 'api-token',
+            'driver'   => 'api-token',
         ],
     ],
 
@@ -52,12 +55,18 @@ return [
     'providers' => [
         'users' => [
             'driver' => 'eloquent',
-            'model' => \BookStack\Auth\User::class,
+            'model'  => \BookStack\Auth\User::class,
         ],
+
         'external' => [
             'driver' => 'external-users',
-            'model' => \BookStack\Auth\User::class,
+            'model'  => \BookStack\Auth\User::class,
         ],
+
+        // 'users' => [
+        //     'driver' => 'database',
+        //     'table' => 'users',
+        // ],
     ],
 
     // Resetting Passwords
@@ -67,10 +76,17 @@ return [
     'passwords' => [
         'users' => [
             'provider' => 'users',
-            'email' => 'emails.password',
-            'table' => 'password_resets',
-            'expire' => 60,
+            'email'    => 'emails.password',
+            'table'    => 'password_resets',
+            'expire'   => 60,
+            'throttle' => 60,
         ],
     ],
 
+    // Password Confirmation Timeout
+    // Here you may define the amount of seconds before a password confirmation
+    // times out and the user is prompted to re-enter their password via the
+    // confirmation screen. By default, the timeout lasts for three hours.
+    'password_timeout' => 10800,
+
 ];
index 7aaaa5693fe1cf907e07da54880710f802c9694a..5e929d3730faa5fd4e53f95a056782937531c981 100644 (file)
@@ -23,18 +23,18 @@ return [
     'connections' => [
 
         'pusher' => [
-            'driver' => 'pusher',
-            'key' => env('PUSHER_APP_KEY'),
-            'secret' => env('PUSHER_APP_SECRET'),
-            'app_id' => env('PUSHER_APP_ID'),
+            'driver'  => 'pusher',
+            'key'     => env('PUSHER_APP_KEY'),
+            'secret'  => env('PUSHER_APP_SECRET'),
+            'app_id'  => env('PUSHER_APP_ID'),
             'options' => [
                 'cluster' => env('PUSHER_APP_CLUSTER'),
-                'useTLS' => true,
+                'useTLS'  => true,
             ],
         ],
 
         'redis' => [
-            'driver' => 'redis',
+            'driver'     => 'redis',
             'connection' => 'default',
         ],
 
@@ -46,7 +46,6 @@ return [
             'driver' => 'null',
         ],
 
-
     ],
 
 ];
index 33d3a1a0bb7b02e6471f1088492fa7b5078e0519..86297b2362579d4d4f35ab5b1a9a6bdd8b70e638 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use Illuminate\Support\Str;
+
 /**
  * Caching configuration options.
  *
@@ -38,13 +40,15 @@ return [
         ],
 
         'array' => [
-            'driver' => 'array',
+            'driver'    => 'array',
+            'serialize' => false,
         ],
 
         'database' => [
-            'driver' => 'database',
-            'table'  => 'cache',
-            'connection' => null,
+            'driver'          => 'database',
+            'table'           => 'cache',
+            'connection'      => null,
+            'lock_connection' => null,
         ],
 
         'file' => [
@@ -53,19 +57,36 @@ return [
         ],
 
         'memcached' => [
-            'driver'  => 'memcached',
-            'servers' => env('CACHE_DRIVER') === 'memcached' ? $memcachedServers : [],
+            'driver'        => 'memcached',
+            'options'       => [
+                // Memcached::OPT_CONNECT_TIMEOUT => 2000,
+            ],
+            'servers' => $memcachedServers ?? [],
         ],
 
         'redis' => [
-            'driver' => 'redis',
-            'connection' => 'default',
+            'driver'          => 'redis',
+            'connection'      => 'default',
+            'lock_connection' => 'default',
+        ],
+
+        'octane' => [
+            'driver' => 'octane',
         ],
 
     ],
 
-    // Cache key prefix
-    // Used to prevent collisions in shared cache systems.
-    'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
+    /*
+    |--------------------------------------------------------------------------
+    | Cache Key Prefix
+    |--------------------------------------------------------------------------
+    |
+    | When utilizing a RAM based store such as APC or Memcached, there might
+    | be other applications utilizing the same cache. So, we'll specify a
+    | value to get prefixed to all our keys so we can avoid collisions.
+    |
+    */
+
+    'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
 
 ];
diff --git a/app/Config/clockwork.php b/app/Config/clockwork.php
new file mode 100644 (file)
index 0000000..394af84
--- /dev/null
@@ -0,0 +1,415 @@
+<?php
+
+return [
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Enable Clockwork
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork is enabled by default only when your application is in debug mode. Here you can explicitly enable or
+    | disable Clockwork. When disabled, no data is collected and the api and web ui are inactive.
+    |
+    */
+
+    'enable' => env('CLOCKWORK_ENABLE', false),
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Features
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query
+    | threshold for database queries).
+    |
+    */
+
+    'features' => [
+
+        // Cache usage stats and cache queries including results
+        'cache' => [
+            'enabled' => true,
+
+            // Collect cache queries
+            'collect_queries' => true,
+
+            // Collect values from cache queries (high performance impact with a very high number of queries)
+            'collect_values' => false,
+        ],
+
+        // Database usage stats and queries
+        'database' => [
+            'enabled' => true,
+
+            // Collect database queries (high performance impact with a very high number of queries)
+            'collect_queries' => true,
+
+            // Collect details of models updates (high performance impact with a lot of model updates)
+            'collect_models_actions' => true,
+
+            // Collect details of retrieved models (very high performance impact with a lot of models retrieved)
+            'collect_models_retrieved' => false,
+
+            // Query execution time threshold in miliseconds after which the query will be marked as slow
+            'slow_threshold' => null,
+
+            // Collect only slow database queries
+            'slow_only' => false,
+
+            // Detect and report duplicate (N+1) queries
+            'detect_duplicate_queries' => false,
+        ],
+
+        // Dispatched events
+        'events' => [
+            'enabled' => true,
+
+            // Ignored events (framework events are ignored by default)
+            'ignored_events' => [
+                // App\Events\UserRegistered::class,
+                // 'user.registered'
+            ],
+        ],
+
+        // Laravel log (you can still log directly to Clockwork with laravel log disabled)
+        'log' => [
+            'enabled' => true,
+        ],
+
+        // Sent notifications
+        'notifications' => [
+            'enabled' => true,
+        ],
+
+        // Performance metrics
+        'performance' => [
+            // Allow collecting of client metrics. Requires separate clockwork-browser npm package.
+            'client_metrics' => true,
+        ],
+
+        // Dispatched queue jobs
+        'queue' => [
+            'enabled' => true,
+        ],
+
+        // Redis commands
+        'redis' => [
+            'enabled' => true,
+        ],
+
+        // Routes list
+        'routes' => [
+            'enabled' => false,
+
+            // Collect only routes from particular namespaces (only application routes by default)
+            'only_namespaces' => ['App'],
+        ],
+
+        // Rendered views
+        'views' => [
+            'enabled' => true,
+
+            // Collect views including view data (high performance impact with a high number of views)
+            'collect_data' => false,
+
+            // Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does
+            // not support collecting view data)
+            'use_twig_profiler' => false,
+        ],
+
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Enable web UI
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork comes with a web UI accessibla via http://your.app/clockwork. Here you can enable or disable this
+    | feature. You can also set a custom path for the web UI.
+    |
+    */
+
+    'web' => true,
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Enable toolbar
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature.
+    | Requires a separate clockwork-browser npm library.
+    | For installation instructions see https://underground.works/clockwork/#docs-viewing-data
+    |
+    */
+
+    'toolbar' => true,
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | HTTP requests collection
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected.
+    |
+    */
+
+    'requests' => [
+        // With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you
+        // manually pass a "clockwork-profile" cookie or get/post data key.
+        // Optionally you can specify a "secret" that has to be passed as the value to enable profiling.
+        'on_demand' => false,
+
+        // Collect only errors (requests with HTTP 4xx and 5xx responses)
+        'errors_only' => false,
+
+        // Response time threshold in miliseconds after which the request will be marked as slow
+        'slow_threshold' => null,
+
+        // Collect only slow requests
+        'slow_only' => false,
+
+        // Sample the collected requests (eg. set to 100 to collect only 1 in 100 requests)
+        'sample' => false,
+
+        // List of URIs that should not be collected
+        'except' => [
+            '/horizon/.*', // Laravel Horizon requests
+            '/telescope/.*', // Laravel Telescope requests
+            '/_debugbar/.*', // Laravel DebugBar requests
+        ],
+
+        // List of URIs that should be collected, any other URI will not be collected if not empty
+        'only' => [
+            // '/api/.*'
+        ],
+
+        // Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest
+        'except_preflight' => true,
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Artisan commands collection
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands
+    | should be collected.
+    |
+    */
+
+    'artisan' => [
+        // Enable or disable collection of executed Artisan commands
+        'collect' => false,
+
+        // List of commands that should not be collected (built-in commands are not collected by default)
+        'except' => [
+            // 'inspire'
+        ],
+
+        // List of commands that should be collected, any other command will not be collected if not empty
+        'only' => [
+            // 'inspire'
+        ],
+
+        // Enable or disable collection of command output
+        'collect_output' => false,
+
+        // Enable or disable collection of built-in Laravel commands
+        'except_laravel_commands' => true,
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Queue jobs collection
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should
+    | be collected.
+    |
+    */
+
+    'queue' => [
+        // Enable or disable collection of executed queue jobs
+        'collect' => false,
+
+        // List of queue jobs that should not be collected
+        'except' => [
+            // App\Jobs\ExpensiveJob::class
+        ],
+
+        // List of queue jobs that should be collected, any other queue job will not be collected if not empty
+        'only' => [
+            // App\Jobs\BuggyJob::class
+        ],
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Tests collection
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork can collect data about executed tests. Here you can enable and configure which tests should be
+    | collected.
+    |
+    */
+
+    'tests' => [
+        // Enable or disable collection of ran tests
+        'collect' => false,
+
+        // List of tests that should not be collected
+        'except' => [
+            // Tests\Unit\ExampleTest::class
+        ],
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Enable data collection when Clockwork is disabled
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | You can enable this setting to collect data even when Clockwork is disabled. Eg. for future analysis.
+    |
+    */
+
+    'collect_data_always' => false,
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Metadata storage
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Configure how is the metadata collected by Clockwork stored. Two options are available:
+    |   - files - A simple fast storage implementation storing data in one-per-request files.
+    |   - sql - Stores requests in a sql database. Supports MySQL, Postgresql, Sqlite and requires PDO.
+    |
+    */
+
+    'storage' => 'files',
+
+    // Path where the Clockwork metadata is stored
+    'storage_files_path' => storage_path('clockwork'),
+
+    // Compress the metadata files using gzip, trading a little bit of performance for lower disk usage
+    'storage_files_compress' => false,
+
+    // SQL database to use, can be a name of database configured in database.php or a path to a sqlite file
+    'storage_sql_database' => storage_path('clockwork.sqlite'),
+
+    // SQL table name to use, the table is automatically created and udpated when needed
+    'storage_sql_table' => 'clockwork',
+
+    // Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable
+    'storage_expiration' => 60 * 24 * 7,
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Authentication
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork can be configured to require authentication before allowing access to the collected data. This might be
+    | useful when the application is publicly accessible. Setting to true will enable a simple authentication with a
+    | pre-configured password. You can also pass a class name of a custom implementation.
+    |
+    */
+
+    'authentication' => false,
+
+    // Password for the simple authentication
+    'authentication_password' => 'VerySecretPassword',
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Stack traces collection
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set
+    | whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting
+    | long stack traces considerably increases metadata size.
+    |
+    */
+
+    'stack_traces' => [
+        // Enable or disable collecting of stack traces
+        'enabled' => true,
+
+        // Limit the number of frames to be collected
+        'limit' => 10,
+
+        // List of vendor names to skip when determining caller, common vendors are automatically added
+        'skip_vendors' => [
+            // 'phpunit'
+        ],
+
+        // List of namespaces to skip when determining caller
+        'skip_namespaces' => [
+            // 'Laravel'
+        ],
+
+        // List of class names to skip when determining caller
+        'skip_classes' => [
+            // App\CustomLog::class
+        ],
+
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Serialization
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects
+    | of serialization. Serialization has a large effect on the cpu time and memory usage.
+    |
+    */
+
+    // Maximum depth of serialized multi-level arrays and objects
+    'serialization_depth' => 10,
+
+    // A list of classes that will never be serialized (eg. a common service container class)
+    'serialization_blackbox' => [
+        \Illuminate\Container\Container::class,
+        \Illuminate\Foundation\Application::class,
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Register helpers
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to
+    | access the Clockwork instance.
+    |
+    */
+
+    'register_helpers' => true,
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Send Headers for AJAX request
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | When trying to collect data the AJAX method can sometimes fail if it is missing required headers. For example, an
+    | API might require a version number using Accept headers to route the HTTP request to the correct codebase.
+    |
+    */
+
+    'headers' => [
+        // 'Accept' => 'application/vnd.com.whatever.v1+json',
+    ],
+
+    /*
+    |------------------------------------------------------------------------------------------------------------------
+    | Server-Timing
+    |------------------------------------------------------------------------------------------------------------------
+    |
+    | Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics
+    | in a cross-browser way. Eg. in Chrome, your app, database and timeline event timings will be shown in the Dev
+    | Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false
+    | will disable the feature.
+    |
+    */
+
+    'server_timing' => 10,
+
+];
index 170666ddba86465e0b0a8a66b972116eab52b524..59ac0f31bebc97c492a3ca323f98f3134bb49030 100644 (file)
@@ -59,38 +59,41 @@ return [
     'connections' => [
 
         'mysql' => [
-            'driver'    => 'mysql',
-            'url' => env('DATABASE_URL'),
-            'host'      => $mysql_host,
-            'database'  => env('DB_DATABASE', 'forge'),
-            'username'  => env('DB_USERNAME', 'forge'),
-            'password'  => env('DB_PASSWORD', ''),
-            'unix_socket' => env('DB_SOCKET', ''),
-            'port'      => $mysql_port,
-            'charset'   => 'utf8mb4',
-            'collation' => 'utf8mb4_unicode_ci',
-            'prefix'    => '',
+            'driver'         => 'mysql',
+            'url'            => env('DATABASE_URL'),
+            'host'           => $mysql_host,
+            'database'       => env('DB_DATABASE', 'forge'),
+            'username'       => env('DB_USERNAME', 'forge'),
+            'password'       => env('DB_PASSWORD', ''),
+            'unix_socket'    => env('DB_SOCKET', ''),
+            'port'           => $mysql_port,
+            'charset'        => 'utf8mb4',
+            '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.
+            'prefix'         => env('DB_TABLE_PREFIX', ''),
             'prefix_indexes' => true,
-            'strict'    => false,
-            'engine' => null,
-            'options' => extension_loaded('pdo_mysql') ? array_filter([
+            'strict'         => false,
+            'engine'         => null,
+            'options'        => extension_loaded('pdo_mysql') ? array_filter([
                 PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
             ]) : [],
         ],
 
         'mysql_testing' => [
-            'driver'    => 'mysql',
-            'url' => env('TEST_DATABASE_URL'),
-            'host'      => '127.0.0.1',
-            'database'  => 'bookstack-test',
-            'username'  => env('MYSQL_USER', 'bookstack-test'),
-            'password'  => env('MYSQL_PASSWORD', 'bookstack-test'),
-            'port'      => $mysql_port,
-            'charset'   => 'utf8mb4',
-            'collation' => 'utf8mb4_unicode_ci',
-            'prefix'    => '',
+            'driver'         => 'mysql',
+            'url'            => env('TEST_DATABASE_URL'),
+            'host'           => '127.0.0.1',
+            'database'       => 'bookstack-test',
+            'username'       => env('MYSQL_USER', 'bookstack-test'),
+            'password'       => env('MYSQL_PASSWORD', 'bookstack-test'),
+            'port'           => $mysql_port,
+            'charset'        => 'utf8mb4',
+            'collation'      => 'utf8mb4_unicode_ci',
+            'prefix'         => '',
             'prefix_indexes' => true,
-            'strict'    => false,
+            'strict'         => false,
         ],
 
     ],
@@ -102,6 +105,6 @@ return [
     'migrations' => 'migrations',
 
     // Redis configuration to use if set
-    'redis' => env('REDIS_SERVERS', false) ? $redisConfig : [],
+    'redis' => $redisConfig ?? [],
 
 ];
index fe624eb7d460c2fa185344f7dd86f3d1906e1bcb..53b5a087249cc7a52d2174c397b32a3da65830ab 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
 /**
- * Debugbar Configuration Options
+ * Debugbar Configuration Options.
  *
  * Changes to these config files are not supported by BookStack and may break upon updates.
  * Configuration should be altered via the `.env` file or environment variables.
 
 return [
 
-     // Debugbar is enabled by default, when debug is set to true in app.php.
-     // You can override the value by setting enable to true or false instead of null.
-     //
-     // You can provide an array of URI's that must be ignored (eg. 'api/*')
+    // Debugbar is enabled by default, when debug is set to true in app.php.
+    // You can override the value by setting enable to true or false instead of null.
+    //
+    // You can provide an array of URI's that must be ignored (eg. 'api/*')
     'enabled' => env('DEBUGBAR_ENABLED', false),
-    'except' => [
-        'telescope*'
+    'except'  => [
+        'telescope*',
     ],
 
-
-     // DebugBar stores data for session/ajax requests.
-     // You can disable this, so the debugbar stores data in headers/session,
-     // but this can cause problems with large data collectors.
-     // By default, file storage (in the storage folder) is used. Redis and PDO
-     // can also be used. For PDO, run the package migrations first.
+    // DebugBar stores data for session/ajax requests.
+    // You can disable this, so the debugbar stores data in headers/session,
+    // but this can cause problems with large data collectors.
+    // By default, file storage (in the storage folder) is used. Redis and PDO
+    // can also be used. For PDO, run the package migrations first.
     'storage' => [
         'enabled'    => true,
         'driver'     => 'file', // redis, file, pdo, custom
         'path'       => storage_path('debugbar'), // For file driver
         'connection' => null,   // Leave null for default connection (Redis/PDO)
-        'provider'   => '' // Instance of StorageInterface for custom driver
+        'provider'   => '', // Instance of StorageInterface for custom driver
     ],
 
-     // Vendor files are included by default, but can be set to false.
-     // This can also be set to 'js' or 'css', to only include javascript or css vendor files.
-     // Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
-     // and for js: jquery and and highlight.js
-     // So if you want syntax highlighting, set it to true.
-     // jQuery is set to not conflict with existing jQuery scripts.
+    // Vendor files are included by default, but can be set to false.
+    // This can also be set to 'js' or 'css', to only include javascript or css vendor files.
+    // Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
+    // and for js: jquery and and highlight.js
+    // So if you want syntax highlighting, set it to true.
+    // jQuery is set to not conflict with existing jQuery scripts.
     'include_vendors' => true,
 
-     // The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
-     // you can use this option to disable sending the data through the headers.
-     // Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
+    // The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
+    // you can use this option to disable sending the data through the headers.
+    // Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
 
-    'capture_ajax' => true,
+    'capture_ajax'    => true,
     'add_ajax_timing' => false,
 
-     // When enabled, the Debugbar shows deprecated warnings for Symfony components
-     // in the Messages tab.
+    // When enabled, the Debugbar shows deprecated warnings for Symfony components
+    // in the Messages tab.
     'error_handler' => false,
 
-     // The Debugbar can emulate the Clockwork headers, so you can use the Chrome
-     // Extension, without the server-side code. It uses Debugbar collectors instead.
+    // The Debugbar can emulate the Clockwork headers, so you can use the Chrome
+    // Extension, without the server-side code. It uses Debugbar collectors instead.
     'clockwork' => false,
 
-     // Enable/disable DataCollectors
+    // Enable/disable DataCollectors
     'collectors' => [
         'phpinfo'         => true,  // Php version
         'messages'        => true,  // Messages
@@ -82,7 +81,7 @@ return [
         'models'          => true, // Display models
     ],
 
-     // Configure some DataCollectors
+    // Configure some DataCollectors
     'options' => [
         'auth' => [
             'show_name' => true,   // Also show the users name/email in the debugbar
@@ -91,43 +90,43 @@ return [
             'with_params'       => true,   // Render SQL with the parameters substituted
             'backtrace'         => true,   // Use a backtrace to find the origin of the query in your files.
             'timeline'          => false,  // Add the queries to the timeline
-            'explain' => [                 // Show EXPLAIN output on queries
+            'explain'           => [                 // Show EXPLAIN output on queries
                 'enabled' => false,
-                'types' => ['SELECT'],     // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
+                'types'   => ['SELECT'],     // ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
             ],
             'hints'             => true,    // Show hints for common mistakes
         ],
         'mail' => [
-            'full_log' => false
+            'full_log' => false,
         ],
         'views' => [
             'data' => false,    //Note: Can slow down the application, because the data can be quite large..
         ],
         'route' => [
-            'label' => true  // show complete route on bar
+            'label' => true,  // show complete route on bar
         ],
         'logs' => [
-            'file' => null
+            'file' => null,
         ],
         'cache' => [
-            'values' => true // collect cache values
+            'values' => true, // collect cache values
         ],
     ],
 
-     // Inject Debugbar into the response
-     // Usually, the debugbar is added just before </body>, by listening to the
-     // Response after the App is done. If you disable this, you have to add them
-     // in your template yourself. See http://phpdebugbar.com/docs/rendering.html
+    // Inject Debugbar into the response
+    // Usually, the debugbar is added just before </body>, by listening to the
+    // Response after the App is done. If you disable this, you have to add them
+    // in your template yourself. See http://phpdebugbar.com/docs/rendering.html
     'inject' => true,
 
-     // DebugBar route prefix
-     // Sometimes you want to set route prefix to be used by DebugBar to load
-     // its resources from. Usually the need comes from misconfigured web server or
-     // from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
+    // DebugBar route prefix
+    // Sometimes you want to set route prefix to be used by DebugBar to load
+    // its resources from. Usually the need comes from misconfigured web server or
+    // from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
     'route_prefix' => '_debugbar',
 
-     // DebugBar route domain
-     // By default DebugBar route served from the same domain that request served.
-     // To override default domain, specify it as a non-empty value.
+    // DebugBar route domain
+    // By default DebugBar route served from the same domain that request served.
+    // To override default domain, specify it as a non-empty value.
     'route_domain' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
 ];
index 87be53df52b373d7c701a590f3051c7066fff711..a8728852c2c9ee05157d1b50e727051b203ca4cb 100644 (file)
@@ -7,15 +7,18 @@
  * Configuration should be altered via the `.env` file or environment variables.
  * Do not edit this file unless you're happy to maintain any changes yourself.
  */
+$dompdfPaperSizeMap = [
+    'a4'     => 'a4',
+    'letter' => 'letter',
+];
 
 return [
 
-
     'show_warnings' => false,   // Throw an Exception on warnings from dompdf
-    'orientation' => 'portrait',
-    'defines' => [
+    'orientation'   => 'portrait',
+    'defines'       => [
         /**
-         * The location of the DOMPDF font directory
+         * The location of the DOMPDF font directory.
          *
          * The location of the directory where DOMPDF will store fonts and font metrics
          * Note: This directory must exist and be writable by the webserver process.
@@ -38,17 +41,17 @@ return [
          * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
          * Symbol, ZapfDingbats.
          */
-        "DOMPDF_FONT_DIR" => app_path('vendor/dompdf/dompdf/lib/fonts/'), //storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
+        'font_dir' => storage_path('fonts/'),  // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
 
         /**
-         * The location of the DOMPDF font cache directory
+         * The location of the DOMPDF font cache directory.
          *
          * This directory contains the cached font metrics for the fonts used by DOMPDF.
          * This directory can be the same as DOMPDF_FONT_DIR
          *
          * Note: This directory must exist and be writable by the webserver process.
          */
-        "DOMPDF_FONT_CACHE" => storage_path('fonts/'),
+        'font_cache' => storage_path('fonts/'),
 
         /**
          * The location of a temporary directory.
@@ -57,10 +60,10 @@ return [
          * The temporary directory is required to download remote images and when
          * using the PFDLib back end.
          */
-        "DOMPDF_TEMP_DIR" => sys_get_temp_dir(),
+        'temp_dir' => sys_get_temp_dir(),
 
         /**
-         * ==== IMPORTANT ====
+         * ==== IMPORTANT ====.
          *
          * dompdf's "chroot": Prevents dompdf from accessing system files or other
          * files on the webserver.  All local files opened by dompdf must be in a
@@ -71,7 +74,7 @@ return [
          * direct class use like:
          * $dompdf = new DOMPDF();  $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
          */
-        "DOMPDF_CHROOT" => realpath(base_path()),
+        'chroot' => realpath(public_path()),
 
         /**
          * Whether to use Unicode fonts or not.
@@ -82,20 +85,19 @@ return [
          * When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
          * document must be present in your fonts, however.
          */
-        "DOMPDF_UNICODE_ENABLED" => true,
+        'unicode_enabled' => true,
 
         /**
          * Whether to enable font subsetting or not.
          */
-        "DOMPDF_ENABLE_FONTSUBSETTING" => false,
+        'enable_fontsubsetting' => false,
 
         /**
-         * The PDF rendering backend to use
+         * The PDF rendering backend to use.
          *
          * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and
          * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will
-         * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link
-         * Canvas_Factory} ultimately determines which rendering class to instantiate
+         * fall back on CPDF. 'GD' renders PDFs to graphic files. {@link * Canvas_Factory} ultimately determines which rendering class to instantiate
          * based on this setting.
          *
          * Both PDFLib & CPDF rendering backends provide sufficient rendering
@@ -117,10 +119,10 @@ return [
          * @link http://www.ros.co.nz/pdf
          * @link http://www.php.net/image
          */
-        "DOMPDF_PDF_BACKEND" => "CPDF",
+        'pdf_backend' => 'CPDF',
 
         /**
-         * PDFlib license key
+         * PDFlib license key.
          *
          * If you are using a licensed, commercial version of PDFlib, specify
          * your license key here.  If you are using PDFlib-Lite or are evaluating
@@ -143,7 +145,7 @@ return [
          * the desired content might be different (e.g. screen or projection view of html file).
          * Therefore allow specification of content here.
          */
-        "DOMPDF_DEFAULT_MEDIA_TYPE" => "print",
+        'default_media_type' => 'print',
 
         /**
          * The default paper size.
@@ -152,18 +154,19 @@ return [
          *
          * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
          */
-        "DOMPDF_DEFAULT_PAPER_SIZE" => "a4",
+        'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
 
         /**
-         * The default font family
+         * The default font family.
          *
          * Used if no suitable fonts can be found. This must exist in the font folder.
+         *
          * @var string
          */
-        "DOMPDF_DEFAULT_FONT" => "dejavu sans",
+        'default_font' => 'dejavu sans',
 
         /**
-         * Image DPI setting
+         * Image DPI setting.
          *
          * This setting determines the default DPI setting for images and fonts.  The
          * DPI may be overridden for inline images by explictly setting the
@@ -195,10 +198,10 @@ return [
          *
          * @var int
          */
-        "DOMPDF_DPI" => 96,
+        'dpi' => 96,
 
         /**
-         * Enable inline PHP
+         * Enable inline PHP.
          *
          * If this setting is set to true then DOMPDF will automatically evaluate
          * inline PHP contained within <script type="text/php"> ... </script> tags.
@@ -209,20 +212,20 @@ return [
          *
          * @var bool
          */
-        "DOMPDF_ENABLE_PHP" => false,
+        'enable_php' => false,
 
         /**
-         * Enable inline Javascript
+         * Enable inline Javascript.
          *
          * If this setting is set to true then DOMPDF will automatically insert
          * JavaScript code contained within <script type="text/javascript"> ... </script> tags.
          *
          * @var bool
          */
-        "DOMPDF_ENABLE_JAVASCRIPT" => true,
+        'enable_javascript' => false,
 
         /**
-         * Enable remote file access
+         * Enable remote file access.
          *
          * If this setting is set to true, DOMPDF will access remote sites for
          * images and CSS files as required.
@@ -238,29 +241,27 @@ return [
          *
          * @var bool
          */
-        "DOMPDF_ENABLE_REMOTE" => true,
+        'enable_remote' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
 
         /**
-         * A ratio applied to the fonts height to be more like browsers' line height
+         * A ratio applied to the fonts height to be more like browsers' line height.
          */
-        "DOMPDF_FONT_HEIGHT_RATIO" => 1.1,
+        'font_height_ratio' => 1.1,
 
         /**
-         * Enable CSS float
+         * Enable CSS float.
          *
          * Allows people to disabled CSS float support
+         *
          * @var bool
          */
-        "DOMPDF_ENABLE_CSS_FLOAT" => true,
-
+        'enable_css_float' => true,
 
         /**
-         * Use the more-than-experimental HTML5 Lib parser
+         * Use the more-than-experimental HTML5 Lib parser.
          */
-        "DOMPDF_ENABLE_HTML5PARSER" => true,
-
+        'enable_html5parser' => true,
 
     ],
 
-
 ];
index 30a5c53691d3a514d852d9d7fbe8f697d7d9321d..493d53bfa2d0fba618031be0402d4b48b123913a 100644 (file)
@@ -25,33 +25,45 @@ return [
     // file storage service, such as s3, to store publicly accessible assets.
     'url' => env('STORAGE_URL', false),
 
-    // Default Cloud Filesystem Disk
-    'cloud' => 's3',
-
     // Available filesystem disks
     // Only local, local_secure & s3 are supported by BookStack
     'disks' => [
 
         'local' => [
-            'driver' => 'local',
-            'root' => public_path(),
+            'driver'     => 'local',
+            'root'       => public_path(),
+            'visibility' => 'public',
         ],
 
-        'local_secure' => [
+        'local_secure_attachments' => [
             'driver' => 'local',
-            'root'   => storage_path(),
+            'root'   => storage_path('uploads/files/'),
+        ],
+
+        'local_secure_images' => [
+            'driver'     => 'local',
+            'root'       => storage_path('uploads/images/'),
+            'visibility' => 'public',
         ],
 
         's3' => [
-            'driver' => 's3',
-            'key'    => env('STORAGE_S3_KEY', 'your-key'),
-            'secret' => env('STORAGE_S3_SECRET', 'your-secret'),
-            'region' => env('STORAGE_S3_REGION', 'your-region'),
-            'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'),
-            'endpoint' => env('STORAGE_S3_ENDPOINT', null),
+            'driver'                  => 's3',
+            'key'                     => env('STORAGE_S3_KEY', 'your-key'),
+            'secret'                  => env('STORAGE_S3_SECRET', 'your-secret'),
+            'region'                  => env('STORAGE_S3_REGION', 'your-region'),
+            'bucket'                  => env('STORAGE_S3_BUCKET', 'your-bucket'),
+            'endpoint'                => env('STORAGE_S3_ENDPOINT', null),
             'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
         ],
 
     ],
 
+    // Symbolic Links
+    // Here you may configure the symbolic links that will be created when the
+    // `storage:link` Artisan command is executed. The array keys should be
+    // the locations of the links and the values should be their targets.
+    'links' => [
+        public_path('storage') => storage_path('app/public'),
+    ],
+
 ];
index 756718ce2bd7239fdd63720661a679db103e6501..585ee094ccb2858c90c41de587757cb87bfca9b0 100644 (file)
@@ -29,9 +29,9 @@ return [
     // passwords are hashed using the Argon algorithm. These will allow you
     // to control the amount of time it takes to hash the given password.
     'argon' => [
-        'memory' => 1024,
+        'memory'  => 1024,
         'threads' => 2,
-        'time' => 2,
+        'time'    => 2,
     ],
 
 ];
index afd56e48256e492a069e1ea996942c6d73940e2e..2b80147c8c7ef5a3d086eeafbce31abb1a4cff49 100644 (file)
@@ -30,66 +30,59 @@ return [
     //                    "custom", "stack"
     'channels' => [
         'stack' => [
-            'driver' => 'stack',
-            'channels' => ['daily'],
+            'driver'            => 'stack',
+            'channels'          => ['daily'],
             'ignore_exceptions' => false,
         ],
 
         'single' => [
             'driver' => 'single',
-            'path' => storage_path('logs/laravel.log'),
-            'level' => 'debug',
-            'days' => 14,
+            'path'   => storage_path('logs/laravel.log'),
+            'level'  => 'debug',
+            'days'   => 14,
         ],
 
         'daily' => [
             'driver' => 'daily',
-            'path' => storage_path('logs/laravel.log'),
-            'level' => 'debug',
-            'days' => 7,
-        ],
-
-        'slack' => [
-            'driver' => 'slack',
-            'url' => env('LOG_SLACK_WEBHOOK_URL'),
-            'username' => 'Laravel Log',
-            'emoji' => ':boom:',
-            'level' => 'critical',
+            'path'   => storage_path('logs/laravel.log'),
+            'level'  => 'debug',
+            'days'   => 7,
         ],
 
         'stderr' => [
-            'driver' => 'monolog',
+            'driver'  => 'monolog',
+            'level'   => 'debug',
             'handler' => StreamHandler::class,
-            'with' => [
+            'with'    => [
                 'stream' => 'php://stderr',
             ],
         ],
 
         'syslog' => [
             'driver' => 'syslog',
-            'level' => 'debug',
+            'level'  => 'debug',
         ],
 
         'errorlog' => [
             'driver' => 'errorlog',
-            'level' => 'debug',
+            'level'  => 'debug',
         ],
 
         // Custom errorlog implementation that logs out a plain,
         // non-formatted message intended for the webserver log.
         'errorlog_plain_webserver' => [
-            'driver' => 'monolog',
-            'level' => 'debug',
-            'handler' => ErrorLogHandler::class,
-            'handler_with' => [4],
-            'formatter' => LineFormatter::class,
+            'driver'         => 'monolog',
+            'level'          => 'debug',
+            'handler'        => ErrorLogHandler::class,
+            'handler_with'   => [4],
+            'formatter'      => LineFormatter::class,
             'formatter_with' => [
-                'format' => "%message%",
+                'format' => '%message%',
             ],
         ],
 
         'null' => [
-            'driver' => 'monolog',
+            'driver'  => 'monolog',
             'handler' => NullHandler::class,
         ],
 
@@ -99,8 +92,11 @@ return [
         'testing' => [
             'driver' => 'testing',
         ],
-    ],
 
+        'emergency' => [
+            'path' => storage_path('logs/laravel.log'),
+        ],
+    ],
 
     // Failed Login Message
     // Allows a configurable message to be logged when a login request fails.
index abdbd382c862a36ec0c769729d0de87dbad9660d..b223e382a515c6723a9c52be76d10a04494eb841 100644 (file)
@@ -11,6 +11,8 @@
 return [
 
     // Mail driver to use.
+    // From Laravel 7+ this is MAIL_MAILER in laravel.
+    // Kept as MAIL_DRIVER in BookStack to prevent breaking change.
     // Options: smtp, sendmail, log, array
     'driver' => env('MAIL_DRIVER', 'smtp'),
 
@@ -23,7 +25,7 @@ return [
     // Global "From" address & name
     'from' => [
         'address' => env('MAIL_FROM', '[email protected]'),
-        'name' => env('MAIL_FROM_NAME', 'BookStack')
+        'name'    => env('MAIL_FROM_NAME', 'BookStack'),
     ],
 
     // Email encryption protocol
diff --git a/app/Config/oidc.php b/app/Config/oidc.php
new file mode 100644 (file)
index 0000000..842ac8a
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+return [
+
+    // Display name, shown to users, for OpenId option
+    'name' => env('OIDC_NAME', 'SSO'),
+
+    // Dump user details after a login request for debugging purposes
+    'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
+
+    // Attribute, within a OpenId token, to find the user's display name
+    'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
+
+    // OAuth2/OpenId client id, as configured in your Authorization server.
+    'client_id' => env('OIDC_CLIENT_ID', null),
+
+    // OAuth2/OpenId client secret, as configured in your Authorization server.
+    'client_secret' => env('OIDC_CLIENT_SECRET', null),
+
+    // The issuer of the identity token (id_token) this will be compared with
+    // what is returned in the token.
+    'issuer' => env('OIDC_ISSUER', null),
+
+    // Auto-discover the relevant endpoints and keys from the issuer.
+    // Fetched details are cached for 15 minutes.
+    'discover' => env('OIDC_ISSUER_DISCOVER', false),
+
+    // Public key that's used to verify the JWT token with.
+    // Can be the key value itself or a local 'file://public.key' reference.
+    'jwt_public_key' => env('OIDC_PUBLIC_KEY', null),
+
+    // OAuth2 endpoints.
+    'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
+    'token_endpoint'         => env('OIDC_TOKEN_ENDPOINT', null),
+];
index 46f6962c5f327f4fd644f029d3159eece4f6701a..a14799f354d87d0f9652afd02c5513b63daf7a3e 100644 (file)
 return [
 
     // Default driver to use for the queue
-    // Options: null, sync, redis
+    // Options: sync, database, redis
     'default' => env('QUEUE_CONNECTION', 'sync'),
 
     // Queue connection configuration
     'connections' => [
 
-
         'sync' => [
             'driver' => 'sync',
         ],
 
         'database' => [
-            'driver' => 'database',
-            'table' => 'jobs',
-            'queue' => 'default',
-            'retry_after' => 90,
+            'driver'       => 'database',
+            'table'        => 'jobs',
+            'queue'        => 'default',
+            'retry_after'  => 90,
+            'after_commit' => false,
         ],
 
         'redis' => [
-            'driver' => 'redis',
-            'connection' => 'default',
-            'queue' => env('REDIS_QUEUE', 'default'),
-            'retry_after' => 90,
-            'block_for' => null,
+            'driver'       => 'redis',
+            'connection'   => 'default',
+            'queue'        => env('REDIS_QUEUE', 'default'),
+            'retry_after'  => 90,
+            'block_for'    => null,
+            'after_commit' => false,
         ],
 
     ],
 
     // Failed queue job logging
     'failed' => [
-        'database' => 'mysql', 'table' => 'failed_jobs',
+        'driver'   => 'database-uuids',
+        'database' => 'mysql',
+        'table'    => 'failed_jobs',
     ],
 
 ];
index d695abf325d1e5260b14d8325ea503c18bfe1abb..44d06c5b2e60c6bb47af0a6970e3b3566dd1e927 100644 (file)
@@ -1,5 +1,8 @@
 <?php
 
+$SAML2_IDP_AUTHNCONTEXT = env('SAML2_IDP_AUTHNCONTEXT', true);
+$SAML2_SP_x509 = env('SAML2_SP_x509', false);
+
 return [
 
     // Display name, shown to users, for SAML2 option
@@ -29,7 +32,6 @@ return [
     // Overrides, in JSON format, to the configuration passed to underlying onelogin library.
     'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null),
 
-
     'onelogin' => [
         // If 'strict' is True, then the PHP Toolkit will reject unsigned
         // or unencrypted messages if it expects them signed or encrypted
@@ -77,10 +79,11 @@ return [
             // represent the requested subject.
             // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
             'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
+
             // Usually x509cert and privateKey of the SP are provided by files placed at
             // the certs folder. But we can also provide them with the following parameters
-            'x509cert' => '',
-            'privateKey' => '',
+            'x509cert'   => $SAML2_SP_x509 ?: '',
+            'privateKey' => env('SAML2_SP_x509_KEY', ''),
         ],
         // Identity Provider Data that we want connect with our SP
         'idp' => [
@@ -139,6 +142,19 @@ return [
             //      )
             // ),
         ],
+        'security' => [
+            // SAML2 Authn context
+            // When set to false no AuthContext will be sent in the AuthNRequest,
+            // When set to true (Default) you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'.
+            // Multiple forced values can be passed via a space separated array, For example:
+            // SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
+            'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT,
+            // Sign requests and responses if a certificate is in use
+            'logoutRequestSigned'   => (bool) $SAML2_SP_x509,
+            'logoutResponseSigned'  => (bool) $SAML2_SP_x509,
+            'authnRequestsSigned'   => (bool) $SAML2_SP_x509,
+            'lowercaseUrlencoding'  => false,
+        ],
     ],
 
 ];
index 6993396147af9b02eec0a778c81b7bb83a6764c3..2d7253fb81e71edc3c593102003a08ceb98b7bc1 100644 (file)
@@ -28,16 +28,16 @@ return [
         'redirect'      => env('APP_URL') . '/login/service/github/callback',
         'name'          => 'GitHub',
         'auto_register' => env('GITHUB_AUTO_REGISTER', false),
-        'auto_confirm' => env('GITHUB_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('GITHUB_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'google'   => [
-        'client_id'     => env('GOOGLE_APP_ID', false),
-        'client_secret' => env('GOOGLE_APP_SECRET', false),
-        'redirect'      => env('APP_URL') . '/login/service/google/callback',
-        'name'          => 'Google',
-        'auto_register' => env('GOOGLE_AUTO_REGISTER', false),
-        'auto_confirm' => env('GOOGLE_AUTO_CONFIRM_EMAIL', false),
+        'client_id'      => env('GOOGLE_APP_ID', false),
+        'client_secret'  => env('GOOGLE_APP_SECRET', false),
+        'redirect'       => env('APP_URL') . '/login/service/google/callback',
+        'name'           => 'Google',
+        'auto_register'  => env('GOOGLE_AUTO_REGISTER', false),
+        'auto_confirm'   => env('GOOGLE_AUTO_CONFIRM_EMAIL', false),
         'select_account' => env('GOOGLE_SELECT_ACCOUNT', false),
     ],
 
@@ -47,7 +47,7 @@ return [
         'redirect'      => env('APP_URL') . '/login/service/slack/callback',
         'name'          => 'Slack',
         'auto_register' => env('SLACK_AUTO_REGISTER', false),
-        'auto_confirm' => env('SLACK_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('SLACK_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'facebook'   => [
@@ -56,7 +56,7 @@ return [
         'redirect'      => env('APP_URL') . '/login/service/facebook/callback',
         'name'          => 'Facebook',
         'auto_register' => env('FACEBOOK_AUTO_REGISTER', false),
-        'auto_confirm' => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('FACEBOOK_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'twitter'   => [
@@ -65,27 +65,27 @@ return [
         'redirect'      => env('APP_URL') . '/login/service/twitter/callback',
         'name'          => 'Twitter',
         'auto_register' => env('TWITTER_AUTO_REGISTER', false),
-        'auto_confirm' => env('TWITTER_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('TWITTER_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'azure'   => [
         'client_id'     => env('AZURE_APP_ID', false),
         'client_secret' => env('AZURE_APP_SECRET', false),
-        'tenant'       => env('AZURE_TENANT', false),
+        'tenant'        => env('AZURE_TENANT', false),
         'redirect'      => env('APP_URL') . '/login/service/azure/callback',
         'name'          => 'Microsoft Azure',
         'auto_register' => env('AZURE_AUTO_REGISTER', false),
-        'auto_confirm' => env('AZURE_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('AZURE_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'okta' => [
-        'client_id' => env('OKTA_APP_ID'),
+        'client_id'     => env('OKTA_APP_ID'),
         'client_secret' => env('OKTA_APP_SECRET'),
-        'redirect' => env('APP_URL') . '/login/service/okta/callback',
-        'base_url' => env('OKTA_BASE_URL'),
+        'redirect'      => env('APP_URL') . '/login/service/okta/callback',
+        'base_url'      => env('OKTA_BASE_URL'),
         'name'          => 'Okta',
         'auto_register' => env('OKTA_AUTO_REGISTER', false),
-        'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('OKTA_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'gitlab' => [
@@ -95,44 +95,45 @@ return [
         'instance_uri'  => env('GITLAB_BASE_URI'), // Needed only for self hosted instances
         'name'          => 'GitLab',
         'auto_register' => env('GITLAB_AUTO_REGISTER', false),
-        'auto_confirm' => env('GITLAB_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('GITLAB_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'twitch' => [
-        'client_id' => env('TWITCH_APP_ID'),
+        'client_id'     => env('TWITCH_APP_ID'),
         'client_secret' => env('TWITCH_APP_SECRET'),
-        'redirect' => env('APP_URL') . '/login/service/twitch/callback',
+        'redirect'      => env('APP_URL') . '/login/service/twitch/callback',
         'name'          => 'Twitch',
         'auto_register' => env('TWITCH_AUTO_REGISTER', false),
-        'auto_confirm' => env('TWITCH_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('TWITCH_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'discord' => [
-        'client_id' => env('DISCORD_APP_ID'),
+        'client_id'     => env('DISCORD_APP_ID'),
         'client_secret' => env('DISCORD_APP_SECRET'),
-        'redirect' => env('APP_URL') . '/login/service/discord/callback',
-        'name' => 'Discord',
+        'redirect'      => env('APP_URL') . '/login/service/discord/callback',
+        'name'          => 'Discord',
         'auto_register' => env('DISCORD_AUTO_REGISTER', false),
-        'auto_confirm' => env('DISCORD_AUTO_CONFIRM_EMAIL', false),
+        'auto_confirm'  => env('DISCORD_AUTO_CONFIRM_EMAIL', false),
     ],
 
     'ldap' => [
-        'server' => env('LDAP_SERVER', false),
-        'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
-        'dn' => env('LDAP_DN', false),
-        'pass' => env('LDAP_PASS', false),
-        'base_dn' => env('LDAP_BASE_DN', false),
-        'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
-        'version' => env('LDAP_VERSION', false),
-        'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
-        'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
+        'server'                 => env('LDAP_SERVER', false),
+        'dump_user_details'      => env('LDAP_DUMP_USER_DETAILS', false),
+        'dn'                     => env('LDAP_DN', false),
+        'pass'                   => env('LDAP_PASS', false),
+        'base_dn'                => env('LDAP_BASE_DN', false),
+        'user_filter'            => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
+        'version'                => env('LDAP_VERSION', false),
+        'id_attribute'           => env('LDAP_ID_ATTRIBUTE', 'uid'),
+        'email_attribute'        => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
         'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
-        'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
-        'user_to_groups' => env('LDAP_USER_TO_GROUPS', false),
-        'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
-        'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
-        'tls_insecure' => env('LDAP_TLS_INSECURE', false),
-        'start_tls' => env('LDAP_START_TLS', false),
+        'follow_referrals'       => env('LDAP_FOLLOW_REFERRALS', false),
+        'user_to_groups'         => env('LDAP_USER_TO_GROUPS', false),
+        'group_attribute'        => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
+        'remove_from_groups'     => env('LDAP_REMOVE_FROM_GROUPS', false),
+        'tls_insecure'           => env('LDAP_TLS_INSECURE', false),
+        'start_tls'              => env('LDAP_START_TLS', false),
+        'thumbnail_attribute'    => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
     ],
 
 ];
index c750e1ef9a4ff48f93eb836dbdd182ea63a1a14f..4bbb789010ff341a0cf6d6b505201412a5b76819 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-use \Illuminate\Support\Str;
+use Illuminate\Support\Str;
 
 /**
  * Session configuration options.
index 879c636bcda2c2f4961cf3687fccf3f4eb0860b7..cb6082c528619ba420d8e1d657704d9350038b0d 100644 (file)
@@ -26,10 +26,10 @@ return [
 
     // User-level default settings
     'user' => [
-        'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
+        'dark-mode-enabled'     => env('APP_DEFAULT_DARK_MODE', false),
         'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
-        'bookshelf_view_type' =>env('APP_VIEWS_BOOKSHELF', 'grid'),
-        'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
+        'bookshelf_view_type'   => env('APP_VIEWS_BOOKSHELF', 'grid'),
+        'books_view_type'       => env('APP_VIEWS_BOOKS', 'grid'),
     ],
 
 ];
index f347eda23349264d1b3d4118afadbedde0a5990a..8ab10a39c432baa0e6d880136a3584f4e168a021 100644 (file)
@@ -7,6 +7,10 @@
  * Configuration should be altered via the `.env` file or environment variables.
  * Do not edit this file unless you're happy to maintain any changes yourself.
  */
+$snappyPaperSizeMap = [
+    'a4'     => 'A4',
+    'letter' => 'Letter',
+];
 
 return [
     'pdf' => [
@@ -14,7 +18,8 @@ return [
         'binary'  => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
         'timeout' => false,
         'options' => [
-            'outline' => true
+            'outline'   => true,
+            'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
         ],
         'env'     => [],
     ],
index 93ca367a2102739b7067ddac3dfd9416749b6514..7221501979a529861e8e505ce66f9f79d7771160 100644 (file)
@@ -25,11 +25,11 @@ class CleanupImages extends Command
      */
     protected $description = 'Cleanup images and drawings';
 
-
     protected $imageService;
 
     /**
      * Create a new command instance.
+     *
      * @param \BookStack\Uploads\ImageService $imageService
      */
     public function __construct(ImageService $imageService)
@@ -63,6 +63,7 @@ class CleanupImages extends Command
             $this->comment($deleteCount . ' images found that would have been deleted');
             $this->showDeletedImages($deleted);
             $this->comment('Run with -f or --force to perform deletions');
+
             return;
         }
 
index 35356210b66809c62687e8fbb1eaa437bc447106..0fc6c0195663755e2f1072444ef72452158075c6 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
+use BookStack\Actions\View;
 use Illuminate\Console\Command;
 
 class ClearViews extends Command
@@ -22,7 +23,6 @@ class ClearViews extends Command
 
     /**
      * Create a new command instance.
-     *
      */
     public function __construct()
     {
@@ -36,7 +36,7 @@ class ClearViews extends Command
      */
     public function handle()
     {
-        \Views::resetAll();
+        View::clearAll();
         $this->comment('Views cleared');
     }
 }
index d220c59f9ea7cedbf0a77c6e6ccb3bab27d7496e..32adf06839c82d8a78acb8f8ab6e02c621c3d298 100644 (file)
@@ -54,13 +54,14 @@ class CopyShelfPermissions extends Command
 
         if (!$cascadeAll && !$shelfSlug) {
             $this->error('Either a --slug or --all option must be provided.');
+
             return;
         }
 
         if ($cascadeAll) {
             $continue = $this->confirm(
-                'Permission settings for all shelves will be cascaded. '.
-                        'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. '.
+                'Permission settings for all shelves will be cascaded. ' .
+                        'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
                         'Are you sure you want to proceed?'
             );
 
index 3d1a3dca08db4045e528bfcab92c13984cbdeff9..c3faef79c306a3ed518e3fadb38cb930d4a9878d 100644 (file)
@@ -3,7 +3,13 @@
 namespace BookStack\Console\Commands;
 
 use BookStack\Auth\UserRepo;
+use BookStack\Exceptions\NotFoundException;
 use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+use Illuminate\Validation\Rules\Password;
+use Illuminate\Validation\Rules\Unique;
+use Symfony\Component\Console\Command\Command as SymfonyCommand;
 
 class CreateAdmin extends Command
 {
@@ -15,7 +21,8 @@ class CreateAdmin extends Command
     protected $signature = 'bookstack:create-admin
                             {--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}';
+                            {--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)}';
 
     /**
      * The console command description.
@@ -38,46 +45,63 @@ class CreateAdmin extends Command
     /**
      * Execute the console command.
      *
+     * @throws NotFoundException
+     *
      * @return mixed
-     * @throws \BookStack\Exceptions\NotFoundException
      */
     public function handle()
     {
-        $email = trim($this->option('email'));
-        if (empty($email)) {
-            $email = $this->ask('Please specify an email address for the new admin user');
-        }
-        if (mb_strlen($email) < 5 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
-            return $this->error('Invalid email address provided');
-        }
+        $details = $this->snakeCaseOptions();
 
-        if ($this->userRepo->getByEmail($email) !== null) {
-            return $this->error('A user with the provided email already exists!');
+        if (empty($details['email'])) {
+            $details['email'] = $this->ask('Please specify an email address for the new admin user');
         }
 
-        $name = trim($this->option('name'));
-        if (empty($name)) {
-            $name = $this->ask('Please specify an name for the new admin user');
-        }
-        if (mb_strlen($name) < 2) {
-            return $this->error('Invalid name provided');
+        if (empty($details['name'])) {
+            $details['name'] = $this->ask('Please specify a name for the new admin user');
         }
 
-        $password = trim($this->option('password'));
-        if (empty($password)) {
-            $password = $this->secret('Please specify a password for the new admin user');
-        }
-        if (mb_strlen($password) < 5) {
-            return $this->error('Invalid password provided, Must be at least 5 characters');
+        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);
+            }
         }
 
+        $validator = Validator::make($details, [
+            'email'            => ['required', 'email', 'min:5', new Unique('users', 'email')],
+            'name'             => ['required', 'min:2'],
+            'password'         => ['required_without:external_auth_id', Password::default()],
+            'external_auth_id' => ['required_without:password'],
+        ]);
+
+        if ($validator->fails()) {
+            foreach ($validator->errors()->all() as $error) {
+                $this->error($error);
+            }
+
+            return SymfonyCommand::FAILURE;
+        }
 
-        $user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
+        $user = $this->userRepo->create($validator->validated());
         $this->userRepo->attachSystemRole($user, 'admin');
         $this->userRepo->downloadAndAssignUserAvatar($user);
         $user->email_confirmed = true;
         $user->save();
 
         $this->info("Admin account with email \"{$user->email}\" successfully created!");
+
+        return SymfonyCommand::SUCCESS;
+    }
+
+    protected function snakeCaseOptions(): array
+    {
+        $returnOpts = [];
+        foreach ($this->options() as $key => $value) {
+            $returnOpts[str_replace('-', '_', $key)] = $value;
+        }
+
+        return $returnOpts;
     }
 }
index c73c883de2d2bd75535f817e5c10e03eff3e9ff4..5627dd1f80456a7066110833642ed131db4d2d11 100644 (file)
@@ -8,7 +8,6 @@ use Illuminate\Console\Command;
 
 class DeleteUsers extends Command
 {
-
     /**
      * The name and signature of the console command.
      *
@@ -47,7 +46,7 @@ class DeleteUsers extends Command
                     continue;
                 }
                 $this->userRepo->destroy($user);
-                ++$numDeleted;
+                $numDeleted++;
             }
             $this->info("Deleted $numDeleted of $totalUsers total users.");
         } else {
index 3dc3ec0af0e98b33bd3f1d741540dd13e1d319c5..20e3fc7983f8bd7ff472758d20f3693841864ec5 100644 (file)
@@ -2,9 +2,10 @@
 
 namespace BookStack\Console\Commands;
 
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Tools\SearchIndex;
-use DB;
 use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
 
 class RegenerateSearch extends Command
 {
@@ -22,6 +23,9 @@ class RegenerateSearch extends Command
      */
     protected $description = 'Re-index all content for searching';
 
+    /**
+     * @var SearchIndex
+     */
     protected $searchIndex;
 
     /**
@@ -45,8 +49,13 @@ class RegenerateSearch extends Command
             DB::setDefaultConnection($this->option('database'));
         }
 
-        $this->searchIndex->indexAllEntities();
+        $this->searchIndex->indexAllEntities(function (Entity $model, int $processed, int $total): void {
+            $this->info('Indexed ' . class_basename($model) . ' entries (' . $processed . '/' . $total . ')');
+        });
+
         DB::setDefaultConnection($connection);
-        $this->comment('Search index regenerated');
+        $this->line('Search index regenerated!');
+
+        return static::SUCCESS;
     }
 }
diff --git a/app/Console/Commands/ResetMfa.php b/app/Console/Commands/ResetMfa.php
new file mode 100644 (file)
index 0000000..9074a4a
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+namespace BookStack\Console\Commands;
+
+use BookStack\Auth\User;
+use Illuminate\Console\Command;
+
+class ResetMfa extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'bookstack:reset-mfa
+                            {--id= : Numeric ID of the user to reset MFA for}
+                            {--email= : Email address of the user to reset MFA for} 
+                            ';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Reset & Clear any configured MFA methods for the given user';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $id = $this->option('id');
+        $email = $this->option('email');
+        if (!$id && !$email) {
+            $this->error('Either a --id=<number> or --email=<email> option must be provided.');
+
+            return 1;
+        }
+
+        $field = $id ? 'id' : 'email';
+        $value = $id ?: $email;
+
+        /** @var User $user */
+        $user = User::query()
+            ->where($field, '=', $value)
+            ->first();
+
+        if (!$user) {
+            $this->error("A user where {$field}={$value} could not be found.");
+
+            return 1;
+        }
+
+        $this->info("This will delete any configure multi-factor authentication methods for user: \n- ID: {$user->id}\n- Name: {$user->name}\n- Email: {$user->email}\n");
+        $this->info('If multi-factor authentication is required for this user they will be asked to reconfigure their methods on next login.');
+        $confirm = $this->confirm('Are you sure you want to proceed?');
+        if ($confirm) {
+            $user->mfaValues()->delete();
+            $this->info('User MFA methods have been reset.');
+
+            return 0;
+        }
+
+        return 1;
+    }
+}
index 2a16884680729e3dc502909cdc71d76addb70177..a4bb6cf228874f1d4b928c7faedc6baa704b27d0 100644 (file)
@@ -4,7 +4,6 @@ namespace BookStack\Console\Commands;
 
 use Illuminate\Console\Command;
 use Illuminate\Database\Connection;
-use Illuminate\Support\Facades\DB;
 
 class UpdateUrl extends Command
 {
@@ -49,7 +48,8 @@ class UpdateUrl extends Command
 
         $urlPattern = '/https?:\/\/(.+)/';
         if (!preg_match($urlPattern, $oldUrl) || !preg_match($urlPattern, $newUrl)) {
-            $this->error("The given urls are expected to be full urls starting with http:// or https://");
+            $this->error('The given urls are expected to be full urls starting with http:// or https://');
+
             return 1;
         }
 
@@ -58,11 +58,11 @@ class UpdateUrl extends Command
         }
 
         $columnsToUpdateByTable = [
-            "attachments" => ["path"],
-            "pages" => ["html", "text", "markdown"],
-            "images" => ["url"],
-            "settings" => ["value"],
-            "comments" => ["html", "text"],
+            'attachments' => ['path'],
+            'pages'       => ['html', 'text', 'markdown'],
+            'images'      => ['url'],
+            'settings'    => ['value'],
+            'comments'    => ['html', 'text'],
         ];
 
         foreach ($columnsToUpdateByTable as $table => $columns) {
@@ -73,7 +73,7 @@ class UpdateUrl extends Command
         }
 
         $jsonColumnsToUpdateByTable = [
-            "settings" => ["value"],
+            'settings' => ['value'],
         ];
 
         foreach ($jsonColumnsToUpdateByTable as $table => $columns) {
@@ -85,10 +85,11 @@ class UpdateUrl extends Command
             }
         }
 
-        $this->info("URL update procedure complete.");
+        $this->info('URL update procedure complete.');
         $this->info('============================================================================');
         $this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
         $this->info('============================================================================');
+
         return 0;
     }
 
@@ -100,8 +101,9 @@ class UpdateUrl extends Command
     {
         $oldQuoted = $this->db->getPdo()->quote($oldUrl);
         $newQuoted = $this->db->getPdo()->quote($newUrl);
+
         return $this->db->table($table)->update([
-            $column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})")
+            $column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})"),
         ]);
     }
 
@@ -112,8 +114,8 @@ class UpdateUrl extends Command
     protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool
     {
         $dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with  \"{$newUrl}\".\n";
-        $dangerWarning .= "Are you sure you want to proceed?";
-        $backupConfirmation = "This operation could cause issues if used incorrectly. Have you made a backup of your existing database?";
+        $dangerWarning .= 'Are you sure you want to proceed?';
+        $backupConfirmation = 'This operation could cause issues if used incorrectly. Have you made a backup of your existing database?';
 
         return $this->confirm($dangerWarning) && $this->confirm($backupConfirmation);
     }
index a17fc952351df5103709c0c2406f8287ac140215..32808729a545a47904457cb7af9bb67f557178e9 100644 (file)
@@ -23,7 +23,6 @@ class UpgradeDatabaseEncoding extends Command
 
     /**
      * Create a new command instance.
-     *
      */
     public function __construct()
     {
@@ -44,12 +43,12 @@ class UpgradeDatabaseEncoding extends Command
 
         $database = DB::getDatabaseName();
         $tables = DB::select('SHOW TABLES');
-        $this->line('ALTER DATABASE `'.$database.'` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
-        $this->line('USE `'.$database.'`;');
+        $this->line('ALTER DATABASE `' . $database . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+        $this->line('USE `' . $database . '`;');
         $key = 'Tables_in_' . $database;
         foreach ($tables as $table) {
             $tableName = $table->$key;
-            $this->line('ALTER TABLE `'.$tableName.'` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+            $this->line('ALTER TABLE `' . $tableName . '` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
         }
 
         DB::setDefaultConnection($connection);
index e75d93801632b972df5787dafe8094cea2fbc943..11c8018c8d6fca1cec1191c011938d20c2478fe1 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Console;
+<?php
+
+namespace BookStack\Console;
 
 use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -17,7 +19,8 @@ class Kernel extends ConsoleKernel
     /**
      * Define the application's command schedule.
      *
-     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
+     * @param \Illuminate\Console\Scheduling\Schedule $schedule
+     *
      * @return void
      */
     protected function schedule(Schedule $schedule)
@@ -32,6 +35,6 @@ class Kernel extends ConsoleKernel
      */
     protected function commands()
     {
-        $this->load(__DIR__.'/Commands');
+        $this->load(__DIR__ . '/Commands');
     }
 }
index cf7cf296c94fdbd8cdcb3b7dffd59767cb8000ad..797162dfb4fb586e8c0b536bb22bd0dea548bd38 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Tools\ShelfContext;
@@ -6,11 +8,11 @@ use Illuminate\View\View;
 
 class BreadcrumbsViewComposer
 {
-
     protected $entityContextManager;
 
     /**
      * BreadcrumbsViewComposer constructor.
+     *
      * @param ShelfContext $entityContextManager
      */
     public function __construct(ShelfContext $entityContextManager)
@@ -20,6 +22,7 @@ class BreadcrumbsViewComposer
 
     /**
      * Modify data when the view is composed.
+     *
      * @param View $view
      */
     public function compose(View $view)
index c77a57d61a5710edf652082839492a394c21a341..aaf392c7b2782b7f54199d4d7398ce8a3059c7d8 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -8,7 +10,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Entities\Models\PageRevision;
 
 /**
- * Class EntityProvider
+ * Class EntityProvider.
  *
  * Provides access to the core entity models.
  * Wrapped up in this provider since they are often used together
@@ -16,7 +18,6 @@ use BookStack\Entities\Models\PageRevision;
  */
 class EntityProvider
 {
-
     /**
      * @var Bookshelf
      */
@@ -42,7 +43,6 @@ class EntityProvider
      */
     public $pageRevision;
 
-
     public function __construct()
     {
         $this->bookshelf = new Bookshelf();
@@ -55,15 +55,16 @@ class EntityProvider
     /**
      * Fetch all core entity types as an associated array
      * with their basic names as the keys.
+     *
      * @return array<Entity>
      */
     public function all(): array
     {
         return [
             'bookshelf' => $this->bookshelf,
-            'book' => $this->book,
-            'chapter' => $this->chapter,
-            'page' => $this->page,
+            'book'      => $this->book,
+            'chapter'   => $this->chapter,
+            'page'      => $this->page,
         ];
     }
 
@@ -73,6 +74,7 @@ class EntityProvider
     public function get(string $type): Entity
     {
         $type = strtolower($type);
+
         return $this->all()[$type];
     }
 
@@ -86,6 +88,7 @@ class EntityProvider
             $model = $this->get($type);
             $morphClasses[] = $model->getMorphClass();
         }
+
         return $morphClasses;
     }
 }
index 6c56767655c894b22c548e7ff552a07f36940ea6..8217d2cab90e48301433c20740ea7df6ede8a135 100644 (file)
@@ -1,21 +1,30 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
 use Exception;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Support\Collection;
 
 /**
- * Class Book
- * @property string $description
- * @property int $image_id
- * @property Image|null $cover
+ * Class Book.
+ *
+ * @property string                                   $description
+ * @property int                                      $image_id
+ * @property Image|null                               $cover
+ * @property \Illuminate\Database\Eloquent\Collection $chapters
+ * @property \Illuminate\Database\Eloquent\Collection $pages
+ * @property \Illuminate\Database\Eloquent\Collection $directPages
  */
 class Book extends Entity implements HasCoverImage
 {
-    public $searchFactor = 2;
+    use HasFactory;
+
+    public $searchFactor = 1.2;
 
     protected $fillable = ['name', 'description'];
     protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
@@ -30,8 +39,10 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Returns book cover image, if book cover not exists return default cover image.
-     * @param int $width - Width of the image
+     *
+     * @param int $width  - Width of the image
      * @param int $height - Height of the image
+     *
      * @return string
      */
     public function getBookCover($width = 440, $height = 250)
@@ -46,11 +57,12 @@ class Book extends Entity implements HasCoverImage
         } catch (Exception $err) {
             $cover = $default;
         }
+
         return $cover;
     }
 
     /**
-     * Get the cover image of the book
+     * Get the cover image of the book.
      */
     public function cover(): BelongsTo
     {
@@ -67,48 +79,44 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get all pages within this book.
-     * @return HasMany
      */
-    public function pages()
+    public function pages(): HasMany
     {
         return $this->hasMany(Page::class);
     }
 
     /**
      * Get the direct child pages of this book.
-     * @return HasMany
      */
-    public function directPages()
+    public function directPages(): HasMany
     {
         return $this->pages()->where('chapter_id', '=', '0');
     }
 
     /**
      * Get all chapters within this book.
-     * @return HasMany
      */
-    public function chapters()
+    public function chapters(): HasMany
     {
         return $this->hasMany(Chapter::class);
     }
 
     /**
      * Get the shelves this book is contained within.
-     * @return BelongsToMany
      */
-    public function shelves()
+    public function shelves(): BelongsToMany
     {
         return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
     }
 
     /**
      * Get the direct child items within this book.
-     * @return Collection
      */
     public function getDirectChildren(): Collection
     {
-        $pages = $this->directPages()->visible()->get();
-        $chapters = $this->chapters()->visible()->get();
+        $pages = $this->directPages()->scopes('visible')->get();
+        $chapters = $this->chapters()->scopes('visible')->get();
+
         return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
 }
index c73fa3959423598da0e1619c07d9720760374a39..e1ba0b6f708d75a18f5cb38dc38262e7396d61c0 100644 (file)
@@ -1,20 +1,38 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * Class BookChild
- * @property int $book_id
- * @property int $priority
- * @property Book $book
+ * Class BookChild.
+ *
+ * @property int    $book_id
+ * @property int    $priority
+ * @property string $book_slug
+ * @property Book   $book
+ *
  * @method Builder whereSlugs(string $bookSlug, string $childSlug)
  */
 abstract class BookChild extends Entity
 {
+    protected static function boot()
+    {
+        parent::boot();
+
+        // Load book slugs onto these models by default during query-time
+        static::addGlobalScope('book_slug', function (Builder $builder) {
+            $builder->addSelect(['book_slug' => function ($builder) {
+                $builder->select('slug')
+                    ->from('books')
+                    ->whereColumn('books.id', '=', 'book_id');
+            }]);
+        });
+    }
 
     /**
-     * Scope a query to find items where the the child has the given childSlug
+     * Scope a query to find items where the child has the given childSlug
      * where its parent has the bookSlug.
      */
     public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
index 8ffd06d2e2f1b9d22dee6cabb23318976c85ddac..b9ebab92ef88da243a9ef069f35cce0e5ddd0e14 100644 (file)
@@ -1,14 +1,19 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
 class Bookshelf extends Entity implements HasCoverImage
 {
+    use HasFactory;
+
     protected $table = 'bookshelves';
 
-    public $searchFactor = 3;
+    public $searchFactor = 1.2;
 
     protected $fillable = ['name', 'description', 'image_id'];
 
@@ -17,6 +22,7 @@ class Bookshelf extends Entity implements HasCoverImage
     /**
      * Get the books in this shelf.
      * Should not be used directly since does not take into account permissions.
+     *
      * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
      */
     public function books()
@@ -31,7 +37,7 @@ class Bookshelf extends Entity implements HasCoverImage
      */
     public function visibleBooks(): BelongsToMany
     {
-        return $this->books()->visible();
+        return $this->books()->scopes('visible');
     }
 
     /**
@@ -44,8 +50,10 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Returns BookShelf cover image, if cover does not exists return default cover image.
-     * @param int $width - Width of the image
+     *
+     * @param int $width  - Width of the image
      * @param int $height - Height of the image
+     *
      * @return string
      */
     public function getBookCover($width = 440, $height = 250)
@@ -61,11 +69,12 @@ class Bookshelf extends Entity implements HasCoverImage
         } catch (\Exception $err) {
             $cover = $default;
         }
+
         return $cover;
     }
 
     /**
-     * Get the cover image of the shelf
+     * Get the cover image of the shelf.
      */
     public function cover(): BelongsTo
     {
@@ -82,7 +91,9 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Check if this shelf contains the given book.
+     *
      * @param Book $book
+     *
      * @return bool
      */
     public function contains(Book $book): bool
@@ -92,6 +103,7 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Add a book to the end of this shelf.
+     *
      * @param Book $book
      */
     public function appendBook(Book $book)
index 257b19e37e45afe981ffeb7cf6ccfcd82cdefec3..af4bbd8e3a66238d722272358086aa45f4aa970d 100644 (file)
@@ -1,24 +1,32 @@
-<?php namespace BookStack\Entities\Models;
+<?php
 
+namespace BookStack\Entities\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Support\Collection;
 
 /**
- * Class Chapter
+ * Class Chapter.
+ *
  * @property Collection<Page> $pages
+ * @property string           $description
  */
 class Chapter extends BookChild
 {
-    public $searchFactor = 1.3;
+    use HasFactory;
+
+    public $searchFactor = 1.2;
 
-    protected $fillable = ['name', 'description', 'priority', 'book_id'];
+    protected $fillable = ['name', 'description', 'priority'];
     protected $hidden = ['restricted', 'pivot', 'deleted_at'];
 
     /**
      * Get the pages that this chapter contains.
-     * @param string $dir
-     * @return mixed
+     *
+     * @return HasMany<Page>
      */
-    public function pages($dir = 'ASC')
+    public function pages(string $dir = 'ASC'): HasMany
     {
         return $this->hasMany(Page::class)->orderBy('priority', $dir);
     }
@@ -26,11 +34,11 @@ class Chapter extends BookChild
     /**
      * Get the url of this chapter.
      */
-    public function getUrl($path = ''): string
+    public function getUrl(string $path = ''): string
     {
         $parts = [
             'books',
-            urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
+            urlencode($this->book_slug ?? $this->book->slug),
             'chapter',
             urlencode($this->slug),
             trim($path, '/'),
@@ -44,7 +52,8 @@ class Chapter extends BookChild
      */
     public function getVisiblePages(): Collection
     {
-        return $this->pages()->visible()
+        return $this->pages()
+        ->scopes('visible')
         ->orderBy('draft', 'desc')
         ->orderBy('priority', 'asc')
         ->get();
index 1be0ba4c678c17139cf1aa9ca2592f2ca0f692d7..181c9c5803d6441254bd89fc92e66cbddf27c4d0 100644 (file)
@@ -1,15 +1,19 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Models\Entity;
+use BookStack\Interfaces\Deletable;
 use BookStack\Interfaces\Loggable;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
+/**
+ * @property Deletable $deletable
+ */
 class Deletion extends Model implements Loggable
 {
-
     /**
      * Get the related deletable record.
      */
@@ -19,7 +23,7 @@ class Deletion extends Model implements Loggable
     }
 
     /**
-     * The the user that performed the deletion.
+     * Get the user that performed the deletion.
      */
     public function deleter(): BelongsTo
     {
@@ -29,20 +33,34 @@ class Deletion extends Model implements Loggable
     /**
      * Create a new deletion record for the provided entity.
      */
-    public static function createForEntity(Entity $entity): Deletion
+    public static function createForEntity(Entity $entity): self
     {
         $record = (new self())->forceFill([
-            'deleted_by' => user()->id,
+            'deleted_by'     => user()->id,
             'deletable_type' => $entity->getMorphClass(),
-            'deletable_id' => $entity->id,
+            'deletable_id'   => $entity->id,
         ]);
         $record->save();
+
         return $record;
     }
 
     public function logDescriptor(): string
     {
         $deletable = $this->deletable()->first();
-        return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
+
+        if ($deletable instanceof Entity) {
+            return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
+        }
+
+        return "Deletion ({$this->id})";
+    }
+
+    /**
+     * Get a URL for this specific deletion.
+     */
+    public function getUrl(string $path = 'restore'): string
+    {
+        return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
     }
 }
index d4b477304da8848d113b3882517a7d9bcea366f5..7ad78f1d1475d03c6131146335949bb29b29be82 100644 (file)
@@ -1,7 +1,10 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Actions\Activity;
 use BookStack\Actions\Comment;
+use BookStack\Actions\Favourite;
 use BookStack\Actions\Tag;
 use BookStack\Actions\View;
 use BookStack\Auth\Permissions\EntityPermission;
@@ -9,7 +12,11 @@ use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Entities\Tools\SearchIndex;
 use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Facades\Permissions;
+use BookStack\Interfaces\Deletable;
+use BookStack\Interfaces\Favouritable;
+use BookStack\Interfaces\Loggable;
 use BookStack\Interfaces\Sluggable;
+use BookStack\Interfaces\Viewable;
 use BookStack\Model;
 use BookStack\Traits\HasCreatorAndUpdater;
 use BookStack\Traits\HasOwner;
@@ -24,21 +31,23 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * The base class for book-like items such as pages, chapters & books.
  * This is not a database model in itself but extended.
  *
- * @property int $id
- * @property string $name
- * @property string $slug
- * @property Carbon $created_at
- * @property Carbon $updated_at
- * @property int $created_by
- * @property int $updated_by
- * @property boolean $restricted
+ * @property int        $id
+ * @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 bool       $restricted
  * @property Collection $tags
+ *
  * @method static Entity|Builder visible()
  * @method static Entity|Builder hasPermission(string $permission)
  * @method static Builder withLastView()
  * @method static Builder withViewCount()
  */
-abstract class Entity extends Model implements Sluggable
+abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
 {
     use SoftDeletes;
     use HasCreatorAndUpdater;
@@ -100,7 +109,7 @@ abstract class Entity extends Model implements Sluggable
      * Compares this entity to another given entity.
      * Matches by comparing class and id.
      */
-    public function matches(Entity $entity): bool
+    public function matches(self $entity): bool
     {
         return [get_class($this), $this->id] === [get_class($entity), $entity->id];
     }
@@ -108,17 +117,17 @@ abstract class Entity extends Model implements Sluggable
     /**
      * Checks if the current entity matches or contains the given.
      */
-    public function matchesOrContains(Entity $entity): bool
+    public function matchesOrContains(self $entity): bool
     {
         if ($this->matches($entity)) {
             return true;
         }
 
-        if (($entity->isA('chapter') || $entity->isA('page')) && $this->isA('book')) {
+        if (($entity instanceof BookChild) && $this instanceof Book) {
             return $entity->book_id === $this->id;
         }
 
-        if ($entity->isA('page') && $this->isA('chapter')) {
+        if ($entity instanceof Page && $this instanceof Chapter) {
             return $entity->chapter_id === $this->id;
         }
 
@@ -151,11 +160,12 @@ abstract class Entity extends Model implements Sluggable
     }
 
     /**
-     * Get the comments for an entity
+     * Get the comments for an entity.
      */
     public function comments(bool $orderByCreated = true): MorphMany
     {
         $query = $this->morphMany(Comment::class, 'entity');
+
         return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
     }
 
@@ -202,7 +212,9 @@ abstract class Entity extends Model implements Sluggable
 
     /**
      * Check if this instance or class is a certain type of entity.
-     * Examples of $type are 'page', 'book', 'chapter'
+     * Examples of $type are 'page', 'book', 'chapter'.
+     *
+     * @deprecated Use instanceof instead.
      */
     public static function isA(string $type): bool
     {
@@ -215,6 +227,7 @@ abstract class Entity extends Model implements Sluggable
     public static function getType(): string
     {
         $className = array_slice(explode('\\', static::class), -1, 1)[0];
+
         return strtolower($className);
     }
 
@@ -226,15 +239,8 @@ abstract class Entity extends Model implements Sluggable
         if (mb_strlen($this->name) <= $length) {
             return $this->name;
         }
-        return mb_substr($this->name, 0, $length - 3) . '...';
-    }
 
-    /**
-     * Get the body text of this entity.
-     */
-    public function getText(): string
-    {
-        return $this->{$this->textField} ?? '';
+        return mb_substr($this->name, 0, $length - 3) . '...';
     }
 
     /**
@@ -242,17 +248,17 @@ abstract class Entity extends Model implements Sluggable
      */
     public function getExcerpt(int $length = 100): string
     {
-        $text = $this->getText();
+        $text = $this->{$this->textField} ?? '';
 
         if (mb_strlen($text) > $length) {
-            $text = mb_substr($text, 0, $length-3) . '...';
+            $text = mb_substr($text, 0, $length - 3) . '...';
         }
 
         return trim($text);
     }
 
     /**
-     * Get the url of this entity
+     * Get the url of this entity.
      */
     abstract public function getUrl(string $path = '/'): string;
 
@@ -261,14 +267,15 @@ abstract class Entity extends Model implements Sluggable
      * This is the "static" parent and does not include dynamic
      * relations such as shelves to books.
      */
-    public function getParent(): ?Entity
+    public function getParent(): ?self
     {
-        if ($this->isA('page')) {
+        if ($this instanceof Page) {
             return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
         }
-        if ($this->isA('chapter')) {
+        if ($this instanceof Chapter) {
             return $this->book()->withTrashed()->first();
         }
+
         return null;
     }
 
@@ -282,7 +289,7 @@ abstract class Entity extends Model implements Sluggable
     }
 
     /**
-     * Index the current entity for search
+     * Index the current entity for search.
      */
     public function indexForSearch()
     {
@@ -290,11 +297,38 @@ abstract class Entity extends Model implements Sluggable
     }
 
     /**
-     * @inheritdoc
+     * {@inheritdoc}
      */
     public function refreshSlug(): string
     {
         $this->slug = app(SlugGenerator::class)->generate($this);
+
         return $this->slug;
     }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function favourites(): MorphMany
+    {
+        return $this->morphMany(Favourite::class, 'favouritable');
+    }
+
+    /**
+     * Check if the entity is a favourite of the current user.
+     */
+    public function isFavourite(): bool
+    {
+        return $this->favourites()
+            ->where('user_id', '=', user()->id)
+            ->exists();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}";
+    }
 }
index f3a486d1877f32a2ef3fcf9f2145868d94337005..f665efce6d24c8ec6ebb9a25f5f05f92613b80e2 100644 (file)
@@ -1,13 +1,11 @@
 <?php
 
-
 namespace BookStack\Entities\Models;
 
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 interface HasCoverImage
 {
-
     /**
      * Get the cover image for this item.
      */
index 7e397894de35fec52bd6692bf74017eed54cd45a..c28b9a3052c6649f54ced3822e268f91990dc89d 100644 (file)
@@ -1,37 +1,44 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Entities\Tools\PageContent;
+use BookStack\Facades\Permissions;
 use BookStack\Uploads\Attachment;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\HasMany;
-use Permissions;
 
 /**
- * Class Page
- * @property int $chapter_id
- * @property string $html
- * @property string $markdown
- * @property string $text
- * @property bool $template
- * @property bool $draft
- * @property int $revision_count
- * @property Chapter $chapter
+ * Class Page.
+ *
+ * @property int        $chapter_id
+ * @property string     $html
+ * @property string     $markdown
+ * @property string     $text
+ * @property bool       $template
+ * @property bool       $draft
+ * @property int        $revision_count
+ * @property Chapter    $chapter
  * @property Collection $attachments
  */
 class Page extends BookChild
 {
-    protected $fillable = ['name', 'priority', 'markdown'];
+    use HasFactory;
+
+    public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
+    public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
 
-    protected $simpleAttributes = ['name', 'id', 'slug'];
+    protected $fillable = ['name', 'priority'];
 
     public $textField = 'text';
 
     protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
 
     protected $casts = [
-        'draft' => 'boolean',
+        'draft'    => 'boolean',
         'template' => 'boolean',
     ];
 
@@ -41,22 +48,13 @@ class Page extends BookChild
     public function scopeVisible(Builder $query): Builder
     {
         $query = Permissions::enforceDraftVisibilityOnQuery($query);
-        return parent::scopeVisible($query);
-    }
 
-    /**
-     * Converts this page into a simplified array.
-     * @return mixed
-     */
-    public function toSimpleArray()
-    {
-        $array = array_intersect_key($this->toArray(), array_flip($this->simpleAttributes));
-        $array['url'] = $this->getUrl();
-        return $array;
+        return parent::scopeVisible($query);
     }
 
     /**
      * Get the chapter that this page is in, If applicable.
+     *
      * @return BelongsTo
      */
     public function chapter()
@@ -66,24 +64,36 @@ class Page extends BookChild
 
     /**
      * Check if this page has a chapter.
-     * @return bool
      */
-    public function hasChapter()
+    public function hasChapter(): bool
     {
         return $this->chapter()->count() > 0;
     }
 
     /**
      * Get the associated page revisions, ordered by created date.
-     * @return mixed
+     * Only provides actual saved page revision instances, Not drafts.
      */
-    public function revisions()
+    public function revisions(): HasMany
     {
-        return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
+        return $this->allRevisions()
+            ->where('type', '=', 'version')
+            ->orderBy('created_at', 'desc')
+            ->orderBy('id', 'desc');
+    }
+
+    /**
+     * Get all revision instances assigned to this page.
+     * Includes all types of revisions.
+     */
+    public function allRevisions(): HasMany
+    {
+        return $this->hasMany(PageRevision::class);
     }
 
     /**
      * Get the attachments assigned to this page.
+     *
      * @return HasMany
      */
     public function attachments()
@@ -94,11 +104,11 @@ class Page extends BookChild
     /**
      * Get the url of this page.
      */
-    public function getUrl($path = ''): string
+    public function getUrl(string $path = ''): string
     {
         $parts = [
             'books',
-            urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
+            urlencode($this->book_slug ?? $this->book->slug),
             $this->draft ? 'draft' : 'page',
             $this->draft ? $this->id : urlencode($this->slug),
             trim($path, '/'),
@@ -108,7 +118,8 @@ class Page extends BookChild
     }
 
     /**
-     * Get the current revision for the page if existing
+     * Get the current revision for the page if existing.
+     *
      * @return PageRevision|null
      */
     public function getCurrentRevision()
@@ -119,11 +130,12 @@ class Page extends BookChild
     /**
      * Get this page for JSON display.
      */
-    public function forJsonDisplay(): Page
+    public function forJsonDisplay(): self
     {
         $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
         $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
         $refreshed->html = (new PageContent($refreshed))->render();
+
         return $refreshed;
     }
 }
index 76a3b15ffd44ea64e176eb1020773dc0ca41ec70..4daf50536441dda8360648eb6ce12c5eee881965 100644 (file)
@@ -1,64 +1,61 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Models\Page;
 use BookStack\Model;
 use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * Class PageRevision
- * @property int $page_id
+ * Class PageRevision.
+ *
+ * @property int    $page_id
  * @property string $slug
  * @property string $book_slug
- * @property int $created_by
+ * @property int    $created_by
  * @property Carbon $created_at
+ * @property Carbon $updated_at
  * @property string $type
  * @property string $summary
  * @property string $markdown
  * @property string $html
- * @property int $revision_number
+ * @property int    $revision_number
+ * @property Page   $page
+ * @property-read ?User $createdBy
  */
 class PageRevision extends Model
 {
     protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
 
     /**
-     * Get the user that created the page revision
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     * Get the user that created the page revision.
      */
-    public function createdBy()
+    public function createdBy(): BelongsTo
     {
         return $this->belongsTo(User::class, 'created_by');
     }
 
     /**
      * Get the page this revision originates from.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function page()
+    public function page(): BelongsTo
     {
         return $this->belongsTo(Page::class);
     }
 
     /**
      * Get the url for this revision.
-     * @param null|string $path
-     * @return string
      */
-    public function getUrl($path = null)
+    public function getUrl(string $path = ''): string
     {
-        $url = $this->page->getUrl() . '/revisions/' . $this->id;
-        if ($path) {
-            return $url . '/' . trim($path, '/');
-        }
-        return $url;
+        return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
     }
 
     /**
-     * Get the previous revision for the same page if existing
-     * @return \BookStack\Entities\PageRevision|null
+     * Get the previous revision for the same page if existing.
      */
-    public function getPrevious()
+    public function getPrevious(): ?PageRevision
     {
         $id = static::newQuery()->where('page_id', '=', $this->page_id)
             ->where('id', '<', $this->id)
@@ -74,11 +71,11 @@ class PageRevision extends Model
     /**
      * Allows checking of the exact class, Used to check entity type.
      * Included here to align with entities in similar use cases.
-     * (Yup, Bit of an awkward hack)
-     * @param $type
-     * @return bool
+     * (Yup, Bit of an awkward hack).
+     *
+     * @deprecated Use instanceof instead.
      */
-    public static function isA($type)
+    public static function isA(string $type): bool
     {
         return $type === 'revision';
     }
index f55cb8407b34c9ce5b4a1bf672178f320c840db5..4ec8d6c45ac1ad027322d0ccbb26fec418e9e468 100644 (file)
@@ -1,15 +1,17 @@
-<?php namespace BookStack\Entities\Models;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Model;
 
 class SearchTerm extends Model
 {
-
     protected $fillable = ['term', 'entity_id', 'entity_type', 'score'];
     public $timestamps = false;
 
     /**
-     * Get the entity that this term belongs to
+     * Get the entity that this term belongs to.
+     *
      * @return \Illuminate\Database\Eloquent\Relations\MorphTo
      */
     public function entity()
diff --git a/app/Entities/Queries/EntityQuery.php b/app/Entities/Queries/EntityQuery.php
new file mode 100644 (file)
index 0000000..76ab16f
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace BookStack\Entities\Queries;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\EntityProvider;
+
+abstract class EntityQuery
+{
+    protected function permissionService(): PermissionService
+    {
+        return app()->make(PermissionService::class);
+    }
+
+    protected function entityProvider(): EntityProvider
+    {
+        return app()->make(EntityProvider::class);
+    }
+}
diff --git a/app/Entities/Queries/Popular.php b/app/Entities/Queries/Popular.php
new file mode 100644 (file)
index 0000000..e6b22a1
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace BookStack\Entities\Queries;
+
+use BookStack\Actions\View;
+use Illuminate\Support\Facades\DB;
+
+class Popular extends EntityQuery
+{
+    public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
+    {
+        $query = $this->permissionService()
+            ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
+            ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
+            ->groupBy('viewable_id', 'viewable_type')
+            ->orderBy('view_count', 'desc');
+
+        if ($filterModels) {
+            $query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
+        }
+
+        return $query->with('viewable')
+            ->skip($count * ($page - 1))
+            ->take($count)
+            ->get()
+            ->pluck('viewable')
+            ->filter();
+    }
+}
diff --git a/app/Entities/Queries/RecentlyViewed.php b/app/Entities/Queries/RecentlyViewed.php
new file mode 100644 (file)
index 0000000..5a29ecd
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace BookStack\Entities\Queries;
+
+use BookStack\Actions\View;
+use Illuminate\Support\Collection;
+
+class RecentlyViewed extends EntityQuery
+{
+    public function run(int $count, int $page): Collection
+    {
+        $user = user();
+        if ($user === null || $user->isDefault()) {
+            return collect();
+        }
+
+        $query = $this->permissionService()->filterRestrictedEntityRelations(
+            View::query(),
+            'views',
+            'viewable_id',
+            'viewable_type',
+            'view'
+        )
+            ->orderBy('views.updated_at', 'desc')
+            ->where('user_id', '=', user()->id);
+
+        return $query->with('viewable')
+            ->skip(($page - 1) * $count)
+            ->take($count)
+            ->get()
+            ->pluck('viewable')
+            ->filter();
+    }
+}
diff --git a/app/Entities/Queries/TopFavourites.php b/app/Entities/Queries/TopFavourites.php
new file mode 100644 (file)
index 0000000..7522a89
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+namespace BookStack\Entities\Queries;
+
+use BookStack\Actions\Favourite;
+use Illuminate\Database\Query\JoinClause;
+
+class TopFavourites extends EntityQuery
+{
+    public function run(int $count, int $skip = 0)
+    {
+        $user = user();
+        if (is_null($user) || $user->isDefault()) {
+            return collect();
+        }
+
+        $query = $this->permissionService()
+            ->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
+            ->select('favourites.*')
+            ->leftJoin('views', function (JoinClause $join) {
+                $join->on('favourites.favouritable_id', '=', 'views.viewable_id');
+                $join->on('favourites.favouritable_type', '=', 'views.viewable_type');
+                $join->where('views.user_id', '=', user()->id);
+            })
+            ->orderBy('views.views', 'desc')
+            ->where('favourites.user_id', '=', user()->id);
+
+        return $query->with('favouritable')
+            ->skip($skip)
+            ->take($count)
+            ->get()
+            ->pluck('favouritable')
+            ->filter();
+    }
+}
index 8b2e70074fe09a3b95c8688b81061f90fef2760c..6b29dad7b547623058aef4b84760cfc56ef7302f 100644 (file)
@@ -2,24 +2,18 @@
 
 namespace BookStack\Entities\Repos;
 
-use BookStack\Actions\ActivityType;
 use BookStack\Actions\TagRepo;
-use BookStack\Auth\User;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Exceptions\ImageUploadException;
-use BookStack\Facades\Activity;
 use BookStack\Uploads\ImageRepo;
 use Illuminate\Http\UploadedFile;
-use Illuminate\Support\Collection;
 
 class BaseRepo
 {
-
     protected $tagRepo;
     protected $imageRepo;
 
-
     public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
     {
         $this->tagRepo = $tagRepo;
@@ -27,7 +21,7 @@ class BaseRepo
     }
 
     /**
-     * Create a new entity in the system
+     * Create a new entity in the system.
      */
     public function create(Entity $entity, array $input)
     {
@@ -35,7 +29,7 @@ class BaseRepo
         $entity->forceFill([
             'created_by' => user()->id,
             'updated_by' => user()->id,
-            'owned_by' => user()->id,
+            'owned_by'   => user()->id,
         ]);
         $entity->refreshSlug();
         $entity->save();
@@ -72,10 +66,13 @@ class BaseRepo
 
     /**
      * Update the given items' cover image, or clear it.
+     *
+     * @param Entity&HasCoverImage $entity
+     *
      * @throws ImageUploadException
      * @throws \Exception
      */
-    public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false)
+    public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
     {
         if ($coverImage) {
             $this->imageRepo->destroyImage($entity->cover);
index 27d0b407541d02857d8aec46cadbeb71cf7bc840..7c4b280a8ca2b6a2bd690f7adca54add1382f2a2 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
+
+namespace BookStack\Entities\Repos;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Actions\TagRepo;
@@ -15,7 +17,6 @@ use Illuminate\Support\Collection;
 
 class BookRepo
 {
-
     protected $baseRepo;
     protected $tagRepo;
     protected $imageRepo;
@@ -84,13 +85,14 @@ class BookRepo
     }
 
     /**
-     * Create a new book in the system
+     * Create a new book in the system.
      */
     public function create(array $input): Book
     {
         $book = new Book();
         $this->baseRepo->create($book, $input);
-        Activity::addForEntity($book, ActivityType::BOOK_CREATE);
+        Activity::add(ActivityType::BOOK_CREATE, $book);
+
         return $book;
     }
 
@@ -100,12 +102,14 @@ class BookRepo
     public function update(Book $book, array $input): Book
     {
         $this->baseRepo->update($book, $input);
-        Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
+        Activity::add(ActivityType::BOOK_UPDATE, $book);
+
         return $book;
     }
 
     /**
      * Update the given book's cover image, or clear it.
+     *
      * @throws ImageUploadException
      * @throws Exception
      */
@@ -116,13 +120,14 @@ class BookRepo
 
     /**
      * Remove a book from the system.
+     *
      * @throws Exception
      */
     public function destroy(Book $book)
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyBook($book);
-        Activity::addForEntity($book, ActivityType::BOOK_DELETE);
+        Activity::add(ActivityType::BOOK_DELETE, $book);
 
         $trashCan->autoClearOld();
     }
index 649f4b0c461c0b461701bd26508af44fe0ab43c2..ceabba59af456e277b1f5fc49dedf90b11c02a1e 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
+
+namespace BookStack\Entities\Repos;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Book;
@@ -88,7 +90,8 @@ class BookshelfRepo
         $shelf = new Bookshelf();
         $this->baseRepo->create($shelf, $input);
         $this->updateBooks($shelf, $bookIds);
-        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
+        Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
+
         return $shelf;
     }
 
@@ -103,7 +106,8 @@ class BookshelfRepo
             $this->updateBooks($shelf, $bookIds);
         }
 
-        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
+        Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
+
         return $shelf;
     }
 
@@ -120,7 +124,8 @@ class BookshelfRepo
 
         $syncData = Book::visible()
             ->whereIn('id', $bookIds)
-            ->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) {
+            ->pluck('id')
+            ->mapWithKeys(function ($bookId) use ($numericIDs) {
                 return [$bookId => ['order' => $numericIDs->search($bookId)]];
             });
 
@@ -129,6 +134,7 @@ class BookshelfRepo
 
     /**
      * Update the given shelf cover image, or clear it.
+     *
      * @throws ImageUploadException
      * @throws Exception
      */
@@ -164,13 +170,14 @@ class BookshelfRepo
 
     /**
      * Remove a bookshelf from the system.
+     *
      * @throws Exception
      */
     public function destroy(Bookshelf $shelf)
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyShelf($shelf);
-        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
+        Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
         $trashCan->autoClearOld();
     }
 }
index d56874e0d54a9b647ce59f4a84add65fa793f302..2b81891af63ce5fd67410bf62517c728e1e1d3bd 100644 (file)
@@ -1,19 +1,21 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
+
+namespace BookStack\Entities\Repos;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
 use Exception;
-use Illuminate\Support\Collection;
 
 class ChapterRepo
 {
-
     protected $baseRepo;
 
     /**
@@ -26,6 +28,7 @@ class ChapterRepo
 
     /**
      * Get a chapter via the slug.
+     *
      * @throws NotFoundException
      */
     public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
@@ -48,7 +51,8 @@ class ChapterRepo
         $chapter->book_id = $parentBook->id;
         $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
         $this->baseRepo->create($chapter, $input);
-        Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
+        Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
+
         return $chapter;
     }
 
@@ -58,48 +62,67 @@ class ChapterRepo
     public function update(Chapter $chapter, array $input): Chapter
     {
         $this->baseRepo->update($chapter, $input);
-        Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
+        Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
+
         return $chapter;
     }
 
     /**
      * Remove a chapter from the system.
+     *
      * @throws Exception
      */
     public function destroy(Chapter $chapter)
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyChapter($chapter);
-        Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
+        Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
         $trashCan->autoClearOld();
     }
 
     /**
      * Move the given chapter into a new parent book.
      * The $parentIdentifier must be a string of the following format:
-     * 'book:<id>' (book:5)
+     * 'book:<id>' (book:5).
+     *
      * @throws MoveOperationException
+     * @throws PermissionsException
      */
     public function move(Chapter $chapter, string $parentIdentifier): Book
     {
-        $stringExploded = explode(':', $parentIdentifier);
-        $entityType = $stringExploded[0];
-        $entityId = intval($stringExploded[1]);
-
-        if ($entityType !== 'book') {
-            throw new MoveOperationException('Chapters can only be moved into books');
+        $parent = $this->findParentByIdentifier($parentIdentifier);
+        if (is_null($parent)) {
+            throw new MoveOperationException('Book to move chapter into not found');
         }
 
-        /** @var Book $parent */
-        $parent = Book::visible()->where('id', '=', $entityId)->first();
-        if ($parent === null) {
-            throw new MoveOperationException('Book to move chapter into not found');
+        if (!userCan('chapter-create', $parent)) {
+            throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
         }
 
         $chapter->changeBook($parent->id);
         $chapter->rebuildPermissions();
-        Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
+        Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
 
         return $parent;
     }
+
+    /**
+     * Find a page parent entity via an identifier string in the format:
+     * {type}:{id}
+     * Example: (book:5).
+     *
+     * @throws MoveOperationException
+     */
+    public function findParentByIdentifier(string $identifier): ?Book
+    {
+        $stringExploded = explode(':', $identifier);
+        $entityType = $stringExploded[0];
+        $entityId = intval($stringExploded[1]);
+
+        if ($entityType !== 'book') {
+            throw new MoveOperationException('Chapters can only be in books');
+        }
+
+        return Book::visible()->where('id', '=', $entityId)->first();
+    }
 }
index 6a4eaeb1553f834ccafdd3e6b642d160e96d81d3..828c4572fd100f7355d02077477a85a8728bc216 100644 (file)
@@ -1,14 +1,16 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
+
+namespace BookStack\Entities\Repos;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Tools\TrashCan;
-use BookStack\Entities\Models\Page;
-use BookStack\Entities\Models\PageRevision;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\PermissionsException;
@@ -16,11 +18,9 @@ use BookStack\Facades\Activity;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Pagination\LengthAwarePaginator;
-use Illuminate\Support\Collection;
 
 class PageRepo
 {
-
     protected $baseRepo;
 
     /**
@@ -33,6 +33,7 @@ class PageRepo
 
     /**
      * Get a page by ID.
+     *
      * @throws NotFoundException
      */
     public function getById(int $id, array $relations = ['book']): Page
@@ -48,6 +49,7 @@ class PageRepo
 
     /**
      * Get a page its book and own slug.
+     *
      * @throws NotFoundException
      */
     public function getBySlug(string $bookSlug, string $pageSlug): Page
@@ -67,9 +69,10 @@ class PageRepo
      */
     public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
     {
+        /** @var ?PageRevision $revision */
         $revision = PageRevision::query()
             ->whereHas('page', function (Builder $query) {
-                $query->visible();
+                $query->scopes('visible');
             })
             ->where('slug', '=', $pageSlug)
             ->where('type', '=', 'version')
@@ -77,7 +80,8 @@ class PageRepo
             ->orderBy('created_at', 'desc')
             ->with('page')
             ->first();
-        return $revision ? $revision->page : null;
+
+        return $revision->page ?? null;
     }
 
     /**
@@ -119,6 +123,7 @@ class PageRepo
     public function getUserDraft(Page $page): ?PageRevision
     {
         $revision = $this->getUserDraftQuery($page)->first();
+
         return $revision;
     }
 
@@ -128,11 +133,11 @@ class PageRepo
     public function getNewDraftPage(Entity $parent)
     {
         $page = (new Page())->forceFill([
-            'name' => trans('entities.pages_initial_name'),
+            'name'       => trans('entities.pages_initial_name'),
             'created_by' => user()->id,
-            'owned_by' => user()->id,
+            'owned_by'   => user()->id,
             'updated_by' => user()->id,
-            'draft' => true,
+            'draft'      => true,
         ]);
 
         if ($parent instanceof Chapter) {
@@ -144,6 +149,7 @@ class PageRepo
 
         $page->save();
         $page->refresh()->rebuildPermissions();
+
         return $page;
     }
 
@@ -152,8 +158,8 @@ class PageRepo
      */
     public function publishDraft(Page $draft, array $input): Page
     {
-        $this->baseRepo->update($draft, $input);
         $this->updateTemplateStatusAndContentFromInput($draft, $input);
+        $this->baseRepo->update($draft, $input);
 
         $draft->draft = false;
         $draft->revision_count = 1;
@@ -165,7 +171,8 @@ class PageRepo
         $draft->indexForSearch();
         $draft->refresh();
 
-        Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
+        Activity::add(ActivityType::PAGE_CREATE, $draft);
+
         return $draft;
     }
 
@@ -190,7 +197,7 @@ class PageRepo
         $this->getUserDraftQuery($page)->delete();
 
         // Save a revision after updating
-        $summary = trim($input['summary'] ?? "");
+        $summary = trim($input['summary'] ?? '');
         $htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml;
         $nameChanged = isset($input['name']) && $input['name'] !== $oldName;
         $markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
@@ -198,7 +205,8 @@ class PageRepo
             $this->savePageRevision($page, $summary);
         }
 
-        Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
+        Activity::add(ActivityType::PAGE_UPDATE, $page);
+
         return $page;
     }
 
@@ -211,7 +219,7 @@ class PageRepo
         $pageContent = new PageContent($page);
         if (!empty($input['markdown'] ?? '')) {
             $pageContent->setNewMarkdown($input['markdown']);
-        } else {
+        } elseif (isset($input['html'])) {
             $pageContent->setNewHTML($input['html']);
         }
     }
@@ -234,6 +242,7 @@ class PageRepo
         $revision->save();
 
         $this->deleteOldRevisions($page);
+
         return $revision;
     }
 
@@ -244,11 +253,10 @@ class PageRepo
     {
         // If the page itself is a draft simply update that
         if ($page->draft) {
-            if (isset($input['html'])) {
-                (new PageContent($page))->setNewHTML($input['html']);
-            }
+            $this->updateTemplateStatusAndContentFromInput($page, $input);
             $page->fill($input);
             $page->save();
+
             return $page;
         }
 
@@ -260,18 +268,20 @@ class PageRepo
         }
 
         $draft->save();
+
         return $draft;
     }
 
     /**
      * Destroy a page from the system.
+     *
      * @throws Exception
      */
     public function destroy(Page $page)
     {
         $trashCan = new TrashCan();
         $trashCan->softDestroyPage($page);
-        Activity::addForEntity($page, ActivityType::PAGE_DELETE);
+        Activity::add(ActivityType::PAGE_DELETE, $page);
         $trashCan->autoClearOld();
     }
 
@@ -281,6 +291,8 @@ class PageRepo
     public function restoreRevision(Page $page, int $revisionId): Page
     {
         $page->revision_count++;
+
+        /** @var PageRevision $revision */
         $revision = $page->revisions()->where('id', '=', $revisionId)->first();
 
         $page->fill($revision->toArray());
@@ -291,7 +303,7 @@ class PageRepo
         } else {
             $content->setNewHTML($revision->html);
         }
-        
+
         $page->updated_by = user()->id;
         $page->refreshSlug();
         $page->save();
@@ -300,21 +312,23 @@ class PageRepo
         $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
         $this->savePageRevision($page, $summary);
 
-        Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
+        Activity::add(ActivityType::PAGE_RESTORE, $page);
+
         return $page;
     }
 
     /**
      * Move the given page into a new parent book or chapter.
      * The $parentIdentifier must be a string of the following format:
-     * 'book:<id>' (book:5)
+     * 'book:<id>' (book:5).
+     *
      * @throws MoveOperationException
      * @throws PermissionsException
      */
     public function move(Page $page, string $parentIdentifier): Entity
     {
         $parent = $this->findParentByIdentifier($parentIdentifier);
-        if ($parent === null) {
+        if (is_null($parent)) {
             throw new MoveOperationException('Book or chapter to move page into not found');
         }
 
@@ -323,56 +337,23 @@ class PageRepo
         }
 
         $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
-        $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
+        $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
+        $page->changeBook($newBookId);
         $page->rebuildPermissions();
 
-        Activity::addForEntity($page, ActivityType::PAGE_MOVE);
-        return $parent;
-    }
+        Activity::add(ActivityType::PAGE_MOVE, $page);
 
-    /**
-     * Copy an existing page in the system.
-     * Optionally providing a new parent via string identifier and a new name.
-     * @throws MoveOperationException
-     * @throws PermissionsException
-     */
-    public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
-    {
-        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
-        if ($parent === null) {
-            throw new MoveOperationException('Book or chapter to move page into not found');
-        }
-
-        if (!userCan('page-create', $parent)) {
-            throw new PermissionsException('User does not have permission to create a page within the new parent');
-        }
-
-        $copyPage = $this->getNewDraftPage($parent);
-        $pageData = $page->getAttributes();
-
-        // Update name
-        if (!empty($newName)) {
-            $pageData['name'] = $newName;
-        }
-
-        // Copy tags from previous page if set
-        if ($page->tags) {
-            $pageData['tags'] = [];
-            foreach ($page->tags as $tag) {
-                $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
-            }
-        }
-
-        return $this->publishDraft($copyPage, $pageData);
+        return $parent;
     }
 
     /**
-     * Find a page parent entity via a identifier string in the format:
+     * Find a page parent entity via an identifier string in the format:
      * {type}:{id}
-     * Example: (book:5)
+     * Example: (book:5).
+     *
      * @throws MoveOperationException
      */
-    protected function findParentByIdentifier(string $identifier): ?Entity
+    public function findParentByIdentifier(string $identifier): ?Entity
     {
         $stringExploded = explode(':', $identifier);
         $entityType = $stringExploded[0];
@@ -383,6 +364,7 @@ class PageRepo
         }
 
         $parentClass = $entityType === 'book' ? Book::class : Chapter::class;
+
         return $parentClass::visible()->where('id', '=', $entityId)->first();
     }
 
@@ -391,7 +373,7 @@ class PageRepo
      */
     protected function changeParent(Page $page, Entity $parent)
     {
-        $book = ($parent instanceof Book) ? $parent : $parent->book;
+        $book = ($parent instanceof Chapter) ? $parent->book : $parent;
         $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
         $page->save();
 
@@ -420,6 +402,7 @@ class PageRepo
         $draft->book_slug = $page->book->slug;
         $draft->created_by = user()->id;
         $draft->type = 'update_draft';
+
         return $draft;
     }
 
@@ -445,13 +428,15 @@ class PageRepo
     }
 
     /**
-     * Get a new priority for a page
+     * Get a new priority for a page.
      */
     protected function getNewPriority(Page $page): int
     {
         $parent = $page->getParent();
         if ($parent instanceof Chapter) {
+            /** @var ?Page $lastPage */
             $lastPage = $parent->pages('desc')->first();
+
             return $lastPage ? $lastPage->priority + 1 : 0;
         }
 
index 71c8f8393a22dff67f95a4ec931ce236d4641b49..6f11e8cbe528f3002008f8a03d78248d0f3fdb0d 100644 (file)
@@ -1,16 +1,16 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+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\Exceptions\SortOperationException;
 use Illuminate\Support\Collection;
 
 class BookContents
 {
-
     /**
      * @var Book
      */
@@ -35,6 +35,7 @@ class BookContents
             ->where('chapter_id', '=', 0)->max('priority');
         $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
             ->max('priority');
+
         return max($maxChapter, $maxPage, 1);
     }
 
@@ -43,7 +44,7 @@ class BookContents
      */
     public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
     {
-        $pages = $this->getPages($showDrafts);
+        $pages = $this->getPages($showDrafts, $renderPages);
         $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
         $all = collect()->concat($pages)->concat($chapters);
         $chapterMap = $chapters->keyBy('id');
@@ -65,7 +66,7 @@ class BookContents
         $all->each(function (Entity $entity) use ($renderPages) {
             $entity->setRelation('book', $this->book);
 
-            if ($renderPages && $entity->isA('page')) {
+            if ($renderPages && $entity instanceof Page) {
                 $entity->html = (new PageContent($entity))->render();
             }
         });
@@ -83,6 +84,7 @@ class BookContents
             if (isset($entity['draft']) && $entity['draft']) {
                 return -100;
             }
+
             return $entity['priority'] ?? 0;
         };
     }
@@ -90,9 +92,11 @@ class BookContents
     /**
      * Get the visible pages within this book.
      */
-    protected function getPages(bool $showDrafts = false): Collection
+    protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
     {
-        $query = Page::visible()->where('book_id', '=', $this->book->id);
+        $query = Page::visible()
+            ->select($getPageContent ? Page::$contentAttributes : Page::$listAttributes)
+            ->where('book_id', '=', $this->book->id);
 
         if (!$showDrafts) {
             $query->where('draft', '=', false);
@@ -102,109 +106,209 @@ class BookContents
     }
 
     /**
-     * Sort the books content using the given map.
-     * The map is a single-dimension collection of objects in the following format:
-     *   {
-     *     +"id": "294" (ID of item)
-     *     +"sort": 1 (Sort order index)
-     *     +"parentChapter": false (ID of parent chapter, as string, or false)
-     *     +"type": "page" (Entity type of item)
-     *     +"book": "1" (Id of book to place item in)
-     *   }
-     *
+     * Sort the books content using the given sort map.
      * Returns a list of books that were involved in the operation.
-     * @throws SortOperationException
+     *
+     * @returns Book[]
      */
-    public function sortUsingMap(Collection $sortMap): Collection
+    public function sortUsingMap(BookSortMap $sortMap): array
     {
         // Load models into map
-        $this->loadModelsIntoSortMap($sortMap);
-        $booksInvolved = $this->getBooksInvolvedInSort($sortMap);
+        $modelMap = $this->loadModelsFromSortMap($sortMap);
 
-        // Perform the sort
-        $sortMap->each(function ($mapItem) {
-            $this->applySortUpdates($mapItem);
+        // Sort our changes from our map to be chapters first
+        // Since they need to be process to ensure book alignment for child page changes.
+        $sortMapItems = $sortMap->all();
+        usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
+            $aScore = $itemA->type === 'page' ? 2 : 1;
+            $bScore = $itemB->type === 'page' ? 2 : 1;
+
+            return $aScore - $bScore;
         });
 
-        // Update permissions and activity.
-        $booksInvolved->each(function (Book $book) {
+        // Perform the sort
+        foreach ($sortMapItems as $item) {
+            $this->applySortUpdates($item, $modelMap);
+        }
+
+        /** @var Book[] $booksInvolved */
+        $booksInvolved = array_values(array_filter($modelMap, function (string $key) {
+            return strpos($key, 'book:') === 0;
+        }, ARRAY_FILTER_USE_KEY));
+
+        // Update permissions of books involved
+        foreach ($booksInvolved as $book) {
             $book->rebuildPermissions();
-        });
+        }
 
         return $booksInvolved;
     }
 
     /**
      * Using the given sort map item, detect changes for the related model
-     * and update it if required.
+     * and update it if required. Changes where permissions are lacking will
+     * be skipped and not throw an error.
+     *
+     * @param array<string, Entity> $modelMap
      */
-    protected function applySortUpdates(\stdClass $sortMapItem)
+    protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
     {
         /** @var BookChild $model */
-        $model = $sortMapItem->model;
+        $model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
+        if (!$model) {
+            return;
+        }
+
+        $priorityChanged = $model->priority !== $sortMapItem->sort;
+        $bookChanged = $model->book_id !== $sortMapItem->parentBookId;
+        $chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
+
+        // Stop if there's no change
+        if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
+            return;
+        }
 
-        $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
-        $bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
-        $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
+        $currentParentKey = 'book:' . $model->book_id;
+        if ($model instanceof Page && $model->chapter_id) {
+            $currentParentKey = 'chapter:' . $model->chapter_id;
+        }
+
+        $currentParent = $modelMap[$currentParentKey] ?? null;
+        /** @var Book $newBook */
+        $newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
+        /** @var ?Chapter $newChapter */
+        $newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
+
+        if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
+            return;
+        }
 
+        // Action the required changes
         if ($bookChanged) {
-            $model->changeBook($sortMapItem->book);
+            $model->changeBook($newBook->id);
         }
 
         if ($chapterChanged) {
-            $model->chapter_id = intval($sortMapItem->parentChapter);
-            $model->save();
+            $model->chapter_id = $newChapter->id ?? 0;
         }
 
         if ($priorityChanged) {
-            $model->priority = intval($sortMapItem->sort);
+            $model->priority = $sortMapItem->sort;
+        }
+
+        if ($chapterChanged || $priorityChanged) {
             $model->save();
         }
     }
 
     /**
-     * Load models from the database into the given sort map.
+     * Check if the current user has permissions to apply the given sorting change.
+     * Is quite complex since items can gain a different parent change. Acts as a:
+     * - Update of old parent element (Change of content/order).
+     * - Update of sorted/moved element.
+     * - Deletion of element (Relative to parent upon move).
+     * - Creation of element within parent (Upon move to new parent).
      */
-    protected function loadModelsIntoSortMap(Collection $sortMap): void
+    protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
     {
-        $keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
-            return  $sortMapItem->type . ':' . $sortMapItem->id;
-        });
-        $pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
-        $chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
+        // Stop if we can't see the current parent or new book.
+        if (!$currentParent || !$newBook) {
+            return false;
+        }
 
-        $pages = Page::visible()->whereIn('id', $pageIds)->get();
-        $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
+        $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));
 
-        foreach ($pages as $page) {
-            $sortItem = $keyMap->get('page:' . $page->id);
-            $sortItem->model = $page;
+            if (!$hasPermission) {
+                return false;
+            }
         }
 
-        foreach ($chapters as $chapter) {
-            $sortItem = $keyMap->get('chapter:' . $chapter->id);
-            $sortItem->model = $chapter;
+        if ($model instanceof Page) {
+            $parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
+            $hasCurrentParentPermission = userCan($parentPermission, $currentParent);
+
+            // This needs to check if there was an intended chapter location in the original sort map
+            // rather than inferring from the $newChapter since that variable may be null
+            // due to other reasons (Visibility).
+            $newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
+            if (!$newParent) {
+                return false;
+            }
+
+            $hasPageEditPermission = userCan('page-update', $model);
+            $newParentInRightLocation = ($newParent instanceof Book || $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));
+
+            $hasPermission = $hasCurrentParentPermission
+                && $newParentInRightLocation
+                && $hasNewParentPermission
+                && $hasPageEditPermission
+                && $hasDeletePermissionIfMoving
+                && $hasCreatePermissionIfMoving;
+
+            if (!$hasPermission) {
+                return false;
+            }
         }
+
+        return true;
     }
 
     /**
-     * Get the books involved in a sort.
-     * The given sort map should have its models loaded first.
-     * @throws SortOperationException
+     * Load models from the database into the given sort map.
+     *
+     * @return array<string, Entity>
      */
-    protected function getBooksInvolvedInSort(Collection $sortMap): Collection
+    protected function loadModelsFromSortMap(BookSortMap $sortMap): array
     {
-        $bookIdsInvolved = collect([$this->book->id]);
-        $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
-        $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
-        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
+        $modelMap = [];
+        $ids = [
+            'chapter' => [],
+            'page'    => [],
+            'book'    => [],
+        ];
+
+        foreach ($sortMap->all() as $sortMapItem) {
+            $ids[$sortMapItem->type][] = $sortMapItem->id;
+            $ids['book'][] = $sortMapItem->parentBookId;
+            if ($sortMapItem->parentChapterId) {
+                $ids['chapter'][] = $sortMapItem->parentChapterId;
+            }
+        }
 
-        $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
+        $pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
+        /** @var Page $page */
+        foreach ($pages as $page) {
+            $modelMap['page:' . $page->id] = $page;
+            $ids['book'][] = $page->book_id;
+            if ($page->chapter_id) {
+                $ids['chapter'][] = $page->chapter_id;
+            }
+        }
+
+        $chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
+        /** @var Chapter $chapter */
+        foreach ($chapters as $chapter) {
+            $modelMap['chapter:' . $chapter->id] = $chapter;
+            $ids['book'][] = $chapter->book_id;
+        }
 
-        if (count($books) !== count($bookIdsInvolved)) {
-            throw new SortOperationException("Could not find all books requested in sort operation");
+        $books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
+        /** @var Book $book */
+        foreach ($books as $book) {
+            $modelMap['book:' . $book->id] = $book;
         }
 
-        return $books;
+        return $modelMap;
     }
 }
diff --git a/app/Entities/Tools/BookSortMap.php b/app/Entities/Tools/BookSortMap.php
new file mode 100644 (file)
index 0000000..ff1ec76
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+class BookSortMap
+{
+    /**
+     * @var BookSortMapItem[]
+     */
+    protected $mapData = [];
+
+    public function addItem(BookSortMapItem $mapItem): void
+    {
+        $this->mapData[] = $mapItem;
+    }
+
+    /**
+     * @return BookSortMapItem[]
+     */
+    public function all(): array
+    {
+        return $this->mapData;
+    }
+
+    public static function fromJson(string $json): self
+    {
+        $map = new BookSortMap();
+        $mapData = json_decode($json);
+
+        foreach ($mapData as $mapDataItem) {
+            $item = new BookSortMapItem(
+                intval($mapDataItem->id),
+                intval($mapDataItem->sort),
+                $mapDataItem->parentChapter ? intval($mapDataItem->parentChapter) : null,
+                $mapDataItem->type,
+                intval($mapDataItem->book)
+            );
+
+            $map->addItem($item);
+        }
+
+        return $map;
+    }
+}
diff --git a/app/Entities/Tools/BookSortMapItem.php b/app/Entities/Tools/BookSortMapItem.php
new file mode 100644 (file)
index 0000000..f76d87f
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+class BookSortMapItem
+{
+    /**
+     * @var int
+     */
+    public $id;
+
+    /**
+     * @var int
+     */
+    public $sort;
+
+    /**
+     * @var ?int
+     */
+    public $parentChapterId;
+
+    /**
+     * @var string
+     */
+    public $type;
+
+    /**
+     * @var int
+     */
+    public $parentBookId;
+
+    public function __construct(int $id, int $sort, ?int $parentChapterId, string $type, int $parentBookId)
+    {
+        $this->id = $id;
+        $this->sort = $sort;
+        $this->parentChapterId = $parentChapterId;
+        $this->type = $type;
+        $this->parentBookId = $parentBookId;
+    }
+}
diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php
new file mode 100644 (file)
index 0000000..b4923b9
--- /dev/null
@@ -0,0 +1,147 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Actions\Tag;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Uploads\Image;
+use BookStack\Uploads\ImageService;
+use Illuminate\Http\UploadedFile;
+
+class Cloner
+{
+    /**
+     * @var PageRepo
+     */
+    protected $pageRepo;
+
+    /**
+     * @var ChapterRepo
+     */
+    protected $chapterRepo;
+
+    /**
+     * @var BookRepo
+     */
+    protected $bookRepo;
+
+    /**
+     * @var ImageService
+     */
+    protected $imageService;
+
+    public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
+    {
+        $this->pageRepo = $pageRepo;
+        $this->chapterRepo = $chapterRepo;
+        $this->bookRepo = $bookRepo;
+        $this->imageService = $imageService;
+    }
+
+    /**
+     * Clone the given page into the given parent using the provided name.
+     */
+    public function clonePage(Page $original, Entity $parent, string $newName): Page
+    {
+        $copyPage = $this->pageRepo->getNewDraftPage($parent);
+        $pageData = $original->getAttributes();
+
+        // Update name & tags
+        $pageData['name'] = $newName;
+        $pageData['tags'] = $this->entityTagsToInputArray($original);
+
+        return $this->pageRepo->publishDraft($copyPage, $pageData);
+    }
+
+    /**
+     * Clone the given page into the given parent using the provided name.
+     * Clones all child pages.
+     */
+    public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
+    {
+        $chapterDetails = $original->getAttributes();
+        $chapterDetails['name'] = $newName;
+        $chapterDetails['tags'] = $this->entityTagsToInputArray($original);
+
+        $copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
+
+        if (userCan('page-create', $copyChapter)) {
+            /** @var Page $page */
+            foreach ($original->getVisiblePages() as $page) {
+                $this->clonePage($page, $copyChapter, $page->name);
+            }
+        }
+
+        return $copyChapter;
+    }
+
+    /**
+     * Clone the given book.
+     * Clones all child chapters & pages.
+     */
+    public function cloneBook(Book $original, string $newName): Book
+    {
+        $bookDetails = $original->getAttributes();
+        $bookDetails['name'] = $newName;
+        $bookDetails['tags'] = $this->entityTagsToInputArray($original);
+
+        $copyBook = $this->bookRepo->create($bookDetails);
+
+        $directChildren = $original->getDirectChildren();
+        foreach ($directChildren as $child) {
+            if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
+                $this->cloneChapter($child, $copyBook, $child->name);
+            }
+
+            if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
+                $this->clonePage($child, $copyBook, $child->name);
+            }
+        }
+
+        if ($original->cover) {
+            try {
+                $tmpImgFile = tmpfile();
+                $uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
+                $this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
+            } catch (\Exception $exception) {
+            }
+        }
+
+        return $copyBook;
+    }
+
+    /**
+     * Convert an image instance to an UploadedFile instance to mimic
+     * a file being uploaded.
+     */
+    protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
+    {
+        $imgData = $this->imageService->getImageData($image);
+        $tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
+        file_put_contents($tmpImgFilePath, $imgData);
+
+        return new UploadedFile($tmpImgFilePath, basename($image->path));
+    }
+
+    /**
+     * Convert the tags on the given entity to the raw format
+     * that's used for incoming request data.
+     */
+    protected function entityTagsToInputArray(Entity $entity): array
+    {
+        $tags = [];
+
+        /** @var Tag $tag */
+        foreach ($entity->tags as $tag) {
+            $tags[] = ['name' => $tag->name, 'value' => $tag->value];
+        }
+
+        return $tags;
+    }
+}
index eb8f6862f23fe76b703c8f0a2514b2f5d61acae9..f993d332d5ee0e5783e373d40caedee57d367482 100644 (file)
@@ -1,44 +1,52 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
 use BookStack\Uploads\ImageService;
-use DomPDF;
+use DOMDocument;
+use DOMElement;
+use DOMXPath;
 use Exception;
-use SnappyPDF;
 use Throwable;
 
 class ExportFormatter
 {
-
     protected $imageService;
+    protected $pdfGenerator;
 
     /**
      * ExportService constructor.
      */
-    public function __construct(ImageService $imageService)
+    public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
     {
         $this->imageService = $imageService;
+        $this->pdfGenerator = $pdfGenerator;
     }
 
     /**
      * Convert a page to a self-contained HTML file.
      * Includes required CSS & image content. Images are base64 encoded into the HTML.
+     *
      * @throws Throwable
      */
     public function pageToContainedHtml(Page $page)
     {
         $page->html = (new PageContent($page))->render();
         $pageHtml = view('pages.export', [
-            'page' => $page,
+            'page'   => $page,
             'format' => 'html',
         ])->render();
+
         return $this->containHtml($pageHtml);
     }
 
     /**
      * Convert a chapter to a self-contained HTML file.
+     *
      * @throws Throwable
      */
     public function chapterToContainedHtml(Chapter $chapter)
@@ -49,43 +57,50 @@ class ExportFormatter
         });
         $html = view('chapters.export', [
             'chapter' => $chapter,
-            'pages' => $pages,
-            'format' => 'html',
+            'pages'   => $pages,
+            'format'  => 'html',
         ])->render();
+
         return $this->containHtml($html);
     }
 
     /**
      * Convert a book to a self-contained HTML file.
+     *
      * @throws Throwable
      */
     public function bookToContainedHtml(Book $book)
     {
         $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('books.export', [
-            'book' => $book,
+            'book'         => $book,
             'bookChildren' => $bookTree,
-            'format' => 'html',
+            'format'       => 'html',
         ])->render();
+
         return $this->containHtml($html);
     }
 
     /**
      * Convert a page to a PDF file.
+     *
      * @throws Throwable
      */
     public function pageToPdf(Page $page)
     {
         $page->html = (new PageContent($page))->render();
         $html = view('pages.export', [
-            'page' => $page,
+            'page'   => $page,
             'format' => 'pdf',
+            'engine' => $this->pdfGenerator->getActiveEngine(),
         ])->render();
+
         return $this->htmlToPdf($html);
     }
 
     /**
      * Convert a chapter to a PDF file.
+     *
      * @throws Throwable
      */
     public function chapterToPdf(Chapter $chapter)
@@ -97,8 +112,9 @@ class ExportFormatter
 
         $html = view('chapters.export', [
             'chapter' => $chapter,
-            'pages' => $pages,
-            'format' => 'pdf',
+            'pages'   => $pages,
+            'format'  => 'pdf',
+            'engine'  => $this->pdfGenerator->getActiveEngine(),
         ])->render();
 
         return $this->htmlToPdf($html);
@@ -106,38 +122,68 @@ class ExportFormatter
 
     /**
      * Convert a book to a PDF file.
+     *
      * @throws Throwable
      */
     public function bookToPdf(Book $book)
     {
         $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('books.export', [
-            'book' => $book,
+            'book'         => $book,
             'bookChildren' => $bookTree,
-            'format' => 'pdf',
+            'format'       => 'pdf',
+            'engine'       => $this->pdfGenerator->getActiveEngine(),
         ])->render();
+
         return $this->htmlToPdf($html);
     }
 
     /**
      * Convert normal web-page HTML to a PDF.
+     *
      * @throws Exception
      */
     protected function htmlToPdf(string $html): string
     {
-        $containedHtml = $this->containHtml($html);
-        $useWKHTML = config('snappy.pdf.binary') !== false;
-        if ($useWKHTML) {
-            $pdf = SnappyPDF::loadHTML($containedHtml);
-            $pdf->setOption('print-media-type', true);
-        } else {
-            $pdf = DomPDF::loadHTML($containedHtml);
+        $html = $this->containHtml($html);
+        $html = $this->replaceIframesWithLinks($html);
+
+        return $this->pdfGenerator->fromHtml($html);
+    }
+
+    /**
+     * Within the given HTML content, replace any iframe elements
+     * with anchor links within paragraph blocks.
+     */
+    protected function replaceIframesWithLinks(string $html): string
+    {
+        libxml_use_internal_errors(true);
+
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+        $xPath = new DOMXPath($doc);
+
+        $iframes = $xPath->query('//iframe');
+        /** @var DOMElement $iframe */
+        foreach ($iframes as $iframe) {
+            $link = $iframe->getAttribute('src');
+            if (strpos($link, '//') === 0) {
+                $link = 'https:' . $link;
+            }
+
+            $anchor = $doc->createElement('a', $link);
+            $anchor->setAttribute('href', $link);
+            $paragraph = $doc->createElement('p');
+            $paragraph->appendChild($anchor);
+            $iframe->parentNode->replaceChild($paragraph, $iframe);
         }
-        return $pdf->output();
+
+        return $doc->saveHTML();
     }
 
     /**
      * Bundle of the contents of a html file to be self-contained.
+     *
      * @throws Exception
      */
     protected function containHtml(string $htmlContent): string
@@ -194,6 +240,7 @@ class ExportFormatter
         $text = html_entity_decode($text);
         // Add title
         $text = $page->name . "\n\n" . $text;
+
         return $text;
     }
 
@@ -207,6 +254,7 @@ class ExportFormatter
         foreach ($chapter->getVisiblePages() as $page) {
             $text .= $this->pageToPlainText($page);
         }
+
         return $text;
     }
 
@@ -224,6 +272,51 @@ class ExportFormatter
                 $text .= $this->pageToPlainText($bookChild);
             }
         }
+
+        return $text;
+    }
+
+    /**
+     * Convert a page to a Markdown file.
+     */
+    public function pageToMarkdown(Page $page): string
+    {
+        if ($page->markdown) {
+            return '# ' . $page->name . "\n\n" . $page->markdown;
+        }
+
+        return '# ' . $page->name . "\n\n" . (new HtmlToMarkdown($page->html))->convert();
+    }
+
+    /**
+     * Convert a chapter to a Markdown file.
+     */
+    public function chapterToMarkdown(Chapter $chapter): string
+    {
+        $text = '# ' . $chapter->name . "\n\n";
+        $text .= $chapter->description . "\n\n";
+        foreach ($chapter->pages as $page) {
+            $text .= $this->pageToMarkdown($page) . "\n\n";
+        }
+
+        return $text;
+    }
+
+    /**
+     * Convert a book into a plain text string.
+     */
+    public function bookToMarkdown(Book $book): string
+    {
+        $bookTree = (new BookContents($book))->getTree(false, true);
+        $text = '# ' . $book->name . "\n\n";
+        foreach ($bookTree as $bookChild) {
+            if ($bookChild instanceof Chapter) {
+                $text .= $this->chapterToMarkdown($bookChild);
+            } else {
+                $text .= $this->pageToMarkdown($bookChild);
+            }
+        }
+
         return $text;
     }
 }
diff --git a/app/Entities/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php
new file mode 100644 (file)
index 0000000..be4cac4
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
+
+use League\CommonMark\Block\Element\AbstractBlock;
+use League\CommonMark\Block\Element\ListItem;
+use League\CommonMark\Block\Element\Paragraph;
+use League\CommonMark\Block\Renderer\BlockRendererInterface;
+use League\CommonMark\Block\Renderer\ListItemRenderer;
+use League\CommonMark\ElementRendererInterface;
+use League\CommonMark\Extension\TaskList\TaskListItemMarker;
+use League\CommonMark\HtmlElement;
+
+class CustomListItemRenderer implements BlockRendererInterface
+{
+    protected $baseRenderer;
+
+    public function __construct()
+    {
+        $this->baseRenderer = new ListItemRenderer();
+    }
+
+    /**
+     * @return HtmlElement|string|null
+     */
+    public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
+    {
+        $listItem = $this->baseRenderer->render($block, $htmlRenderer, $inTightList);
+
+        if ($this->startsTaskListItem($block)) {
+            $listItem->setAttribute('class', 'task-list-item');
+        }
+
+        return $listItem;
+    }
+
+    private function startsTaskListItem(ListItem $block): bool
+    {
+        $firstChild = $block->firstChild();
+
+        return $firstChild instanceof Paragraph && $firstChild->firstChild() instanceof TaskListItemMarker;
+    }
+}
diff --git a/app/Entities/Tools/Markdown/CustomParagraphConverter.php b/app/Entities/Tools/Markdown/CustomParagraphConverter.php
new file mode 100644 (file)
index 0000000..bd493aa
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
+
+use League\HTMLToMarkdown\Converter\ParagraphConverter;
+use League\HTMLToMarkdown\ElementInterface;
+
+class CustomParagraphConverter extends ParagraphConverter
+{
+    public function convert(ElementInterface $element): string
+    {
+        $class = $element->getAttribute('class');
+        if (strpos($class, 'callout') !== false) {
+            return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}</{$element->getTagName()}>\n\n";
+        }
+
+        return parent::convert($element);
+    }
+}
index d4984ef081dd4965e23e697a7ced6dace0e67c37..a8ccfc4f9bfc116933d80f2486fc9eedd889bb46 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Tools\Markdown;
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
 
 use League\CommonMark\ConfigurableEnvironmentInterface;
 use League\CommonMark\Extension\ExtensionInterface;
@@ -7,7 +9,6 @@ use League\CommonMark\Extension\Strikethrough\StrikethroughDelimiterProcessor;
 
 class CustomStrikeThroughExtension implements ExtensionInterface
 {
-
     public function register(ConfigurableEnvironmentInterface $environment)
     {
         $environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
index 7de95c2637b057ecfcaca84864e3b84f4b873270..ca9f434af0fae8d0a318e73d6e8e9bcac17892b2 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Tools\Markdown;
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
 
 use League\CommonMark\ElementRendererInterface;
 use League\CommonMark\Extension\Strikethrough\Strikethrough;
diff --git a/app/Entities/Tools/Markdown/HtmlToMarkdown.php b/app/Entities/Tools/Markdown/HtmlToMarkdown.php
new file mode 100644 (file)
index 0000000..e880469
--- /dev/null
@@ -0,0 +1,93 @@
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
+
+use League\HTMLToMarkdown\Converter\BlockquoteConverter;
+use League\HTMLToMarkdown\Converter\CodeConverter;
+use League\HTMLToMarkdown\Converter\CommentConverter;
+use League\HTMLToMarkdown\Converter\DivConverter;
+use League\HTMLToMarkdown\Converter\EmphasisConverter;
+use League\HTMLToMarkdown\Converter\HardBreakConverter;
+use League\HTMLToMarkdown\Converter\HeaderConverter;
+use League\HTMLToMarkdown\Converter\HorizontalRuleConverter;
+use League\HTMLToMarkdown\Converter\ImageConverter;
+use League\HTMLToMarkdown\Converter\LinkConverter;
+use League\HTMLToMarkdown\Converter\ListBlockConverter;
+use League\HTMLToMarkdown\Converter\ListItemConverter;
+use League\HTMLToMarkdown\Converter\PreformattedConverter;
+use League\HTMLToMarkdown\Converter\TextConverter;
+use League\HTMLToMarkdown\Environment;
+use League\HTMLToMarkdown\HtmlConverter;
+
+class HtmlToMarkdown
+{
+    protected $html;
+
+    public function __construct(string $html)
+    {
+        $this->html = $html;
+    }
+
+    /**
+     * Run the conversion.
+     */
+    public function convert(): string
+    {
+        $converter = new HtmlConverter($this->getConverterEnvironment());
+        $html = $this->prepareHtml($this->html);
+
+        return $converter->convert($html);
+    }
+
+    /**
+     * Run any pre-processing to the HTML to clean it up manually before conversion.
+     */
+    protected function prepareHtml(string $html): string
+    {
+        // Carriage returns can cause whitespace issues in output
+        $html = str_replace("\r\n", "\n", $html);
+        // Attributes on the pre tag can cause issues with conversion
+        return preg_replace('/<pre .*?>/', '<pre>', $html);
+    }
+
+    /**
+     * Get the HTML to Markdown customized environment.
+     * Extends the default provided environment with some BookStack specific tweaks.
+     */
+    protected function getConverterEnvironment(): Environment
+    {
+        $environment = new Environment([
+            'header_style'            => 'atx', // Set to 'atx' to output H1 and H2 headers as # Header1 and ## Header2
+            'suppress_errors'         => true, // Set to false to show warnings when loading malformed HTML
+            'strip_tags'              => false, // Set to true to strip tags that don't have markdown equivalents. N.B. Strips tags, not their content. Useful to clean MS Word HTML output.
+            'strip_placeholder_links' => false, // Set to true to remove <a> that doesn't have href.
+            'bold_style'              => '**', // DEPRECATED: Set to '__' if you prefer the underlined style
+            'italic_style'            => '*', // DEPRECATED: Set to '_' if you prefer the underlined style
+            'remove_nodes'            => '', // space-separated list of dom nodes that should be removed. example: 'meta style script'
+            'hard_break'              => false, // Set to true to turn <br> into `\n` instead of `  \n`
+            'list_item_style'         => '-', // Set the default character for each <li> in a <ul>. Can be '-', '*', or '+'
+            'preserve_comments'       => false, // Set to true to preserve comments, or set to an array of strings to preserve specific comments
+            'use_autolinks'           => false, // Set to true to use simple link syntax if possible. Will always use []() if set to false
+            'table_pipe_escape'       => '\|', // Replacement string for pipe characters inside markdown table cells
+            'table_caption_side'      => 'top', // Set to 'top' or 'bottom' to show <caption> content before or after table, null to suppress
+        ]);
+
+        $environment->addConverter(new BlockquoteConverter());
+        $environment->addConverter(new CodeConverter());
+        $environment->addConverter(new CommentConverter());
+        $environment->addConverter(new DivConverter());
+        $environment->addConverter(new EmphasisConverter());
+        $environment->addConverter(new HardBreakConverter());
+        $environment->addConverter(new HeaderConverter());
+        $environment->addConverter(new HorizontalRuleConverter());
+        $environment->addConverter(new ImageConverter());
+        $environment->addConverter(new LinkConverter());
+        $environment->addConverter(new ListBlockConverter());
+        $environment->addConverter(new ListItemConverter());
+        $environment->addConverter(new CustomParagraphConverter());
+        $environment->addConverter(new PreformattedConverter());
+        $environment->addConverter(new TextConverter());
+
+        return $environment;
+    }
+}
diff --git a/app/Entities/Tools/NextPreviousContentLocator.php b/app/Entities/Tools/NextPreviousContentLocator.php
new file mode 100644 (file)
index 0000000..54b5759
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use Illuminate\Support\Collection;
+
+/**
+ * Finds the next or previous content of a book element (page or chapter).
+ */
+class NextPreviousContentLocator
+{
+    protected $relativeBookItem;
+    protected $flatTree;
+    protected $currentIndex = null;
+
+    /**
+     * NextPreviousContentLocator constructor.
+     */
+    public function __construct(BookChild $relativeBookItem, Collection $bookTree)
+    {
+        $this->relativeBookItem = $relativeBookItem;
+        $this->flatTree = $this->treeToFlatOrderedCollection($bookTree);
+        $this->currentIndex = $this->getCurrentIndex();
+    }
+
+    /**
+     * Get the next logical entity within the book hierarchy.
+     */
+    public function getNext(): ?Entity
+    {
+        return $this->flatTree->get($this->currentIndex + 1);
+    }
+
+    /**
+     * Get the next logical entity within the book hierarchy.
+     */
+    public function getPrevious(): ?Entity
+    {
+        return $this->flatTree->get($this->currentIndex - 1);
+    }
+
+    /**
+     * Get the index of the current relative item.
+     */
+    protected function getCurrentIndex(): ?int
+    {
+        $index = $this->flatTree->search(function (Entity $entity) {
+            return get_class($entity) === get_class($this->relativeBookItem)
+                && $entity->id === $this->relativeBookItem->id;
+        });
+
+        return $index === false ? null : $index;
+    }
+
+    /**
+     * Convert a book tree collection to a flattened version
+     * where all items follow the expected order of user flow.
+     */
+    protected function treeToFlatOrderedCollection(Collection $bookTree): Collection
+    {
+        $flatOrdered = collect();
+        /** @var Entity $item */
+        foreach ($bookTree->all() as $item) {
+            $flatOrdered->push($item);
+            $childPages = $item->getAttribute('visible_pages') ?? [];
+            $flatOrdered = $flatOrdered->concat($childPages);
+        }
+
+        return $flatOrdered;
+    }
+}
index ff502d1640c5de2252d4d0c1acbce43255c87133..b95131fce2e25561c22131d3d81e3dc4d30a8aac 100644 (file)
@@ -1,13 +1,23 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
 use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
+use BookStack\Exceptions\ImageUploadException;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
+use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageService;
 use BookStack\Util\HtmlContentFilter;
 use DOMDocument;
+use DOMElement;
+use DOMNode;
 use DOMNodeList;
 use DOMXPath;
+use Illuminate\Support\Str;
+use League\CommonMark\Block\Element\ListItem;
 use League\CommonMark\CommonMarkConverter;
 use League\CommonMark\Environment;
 use League\CommonMark\Extension\Table\TableExtension;
@@ -15,7 +25,6 @@ use League\CommonMark\Extension\TaskList\TaskListExtension;
 
 class PageContent
 {
-
     protected $page;
 
     /**
@@ -31,6 +40,7 @@ class PageContent
      */
     public function setNewHTML(string $html)
     {
+        $html = $this->extractBase64ImagesFromHtml($html);
         $this->page->html = $this->formatHtml($html);
         $this->page->text = $this->toPlainText();
         $this->page->markdown = '';
@@ -41,6 +51,7 @@ class PageContent
      */
     public function setNewMarkdown(string $markdown)
     {
+        $markdown = $this->extractBase64ImagesFromMarkdown($markdown);
         $this->page->markdown = $markdown;
         $html = $this->markdownToHtml($markdown);
         $this->page->html = $this->formatHtml($html);
@@ -58,22 +69,118 @@ class PageContent
         $environment->addExtension(new CustomStrikeThroughExtension());
         $environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
         $converter = new CommonMarkConverter([], $environment);
+
+        $environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
+
         return $converter->convertToHtml($markdown);
     }
 
+    /**
+     * Convert all base64 image data to saved images.
+     */
+    protected function extractBase64ImagesFromHtml(string $htmlText): string
+    {
+        if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
+            return $htmlText;
+        }
+
+        $doc = $this->loadDocumentFromHtml($htmlText);
+        $container = $doc->documentElement;
+        $body = $container->childNodes->item(0);
+        $childNodes = $body->childNodes;
+        $xPath = new DOMXPath($doc);
+
+        // Get all img elements with image data blobs
+        $imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
+        foreach ($imageNodes as $imageNode) {
+            $imageSrc = $imageNode->getAttribute('src');
+            $newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc);
+            $imageNode->setAttribute('src', $newUrl);
+        }
+
+        // Generate inner html as a string
+        $html = '';
+        foreach ($childNodes as $childNode) {
+            $html .= $doc->saveHTML($childNode);
+        }
+
+        return $html;
+    }
+
+    /**
+     * Convert all inline base64 content to uploaded image files.
+     */
+    protected function extractBase64ImagesFromMarkdown(string $markdown)
+    {
+        $matches = [];
+        preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
+
+        foreach ($matches[1] as $base64Match) {
+            $newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
+            $markdown = str_replace($base64Match, $newUrl, $markdown);
+        }
+
+        return $markdown;
+    }
+
+    /**
+     * Parse the given base64 image URI and return the URL to the created image instance.
+     * Returns an empty string if the parsed URI is invalid or causes an error upon upload.
+     */
+    protected function base64ImageUriToUploadedImageUrl(string $uri): string
+    {
+        $imageRepo = app()->make(ImageRepo::class);
+        $imageInfo = $this->parseBase64ImageUri($uri);
+
+        // Validate extension and content
+        if (empty($imageInfo['data']) || !ImageService::isExtensionSupported($imageInfo['extension'])) {
+            return '';
+        }
+
+        // Validate that the content is not over our upload limit
+        $uploadLimitBytes = (config('app.upload_limit') * 1000000);
+        if (strlen($imageInfo['data']) > $uploadLimitBytes) {
+            return '';
+        }
+
+        // Save image from data with a random name
+        $imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension'];
+
+        try {
+            $image = $imageRepo->saveNewFromData($imageName, $imageInfo['data'], 'gallery', $this->page->id);
+        } catch (ImageUploadException $exception) {
+            return '';
+        }
+
+        return $image->url;
+    }
+
+    /**
+     * Parse a base64 image URI into the data and extension.
+     *
+     * @return array{extension: string, data: string}
+     */
+    protected function parseBase64ImageUri(string $uri): array
+    {
+        [$dataDefinition, $base64ImageData] = explode(',', $uri, 2);
+        $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? '');
+
+        return [
+            'extension' => $extension,
+            'data'      => base64_decode($base64ImageData) ?: '',
+        ];
+    }
+
     /**
      * Formats a page's html to be tagged correctly within the system.
      */
     protected function formatHtml(string $htmlText): string
     {
-        if ($htmlText == '') {
+        if (empty($htmlText)) {
             return $htmlText;
         }
 
-        libxml_use_internal_errors(true);
-        $doc = new DOMDocument();
-        $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
-
+        $doc = $this->loadDocumentFromHtml($htmlText);
         $container = $doc->documentElement;
         $body = $container->childNodes->item(0);
         $childNodes = $body->childNodes;
@@ -88,6 +195,15 @@ class PageContent
             }
         }
 
+        // Set ids on nested header nodes
+        $nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
+        foreach ($nestedHeaders as $nestedHeader) {
+            [$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
+            if ($newId && $newId !== $oldId) {
+                $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
+            }
+        }
+
         // Ensure no duplicate ids within child items
         $idElems = $xPath->query('//body//*//*[@id]');
         foreach ($idElems as $domElem) {
@@ -112,7 +228,7 @@ class PageContent
     protected function updateLinks(DOMXPath $xpath, string $old, string $new)
     {
         $old = str_replace('"', '', $old);
-        $matchingLinks = $xpath->query('//body//*//*[@href="'.$old.'"]');
+        $matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]');
         foreach ($matchingLinks as $domElem) {
             $domElem->setAttribute('href', $new);
         }
@@ -121,11 +237,11 @@ class PageContent
     /**
      * Set a unique id on the given DOMElement.
      * A map for existing ID's should be passed in to check for current existence.
-     * Returns a pair of strings in the format [old_id, new_id]
+     * Returns a pair of strings in the format [old_id, new_id].
      */
-    protected function setUniqueId(\DOMNode $element, array &$idMap): array
+    protected function setUniqueId(DOMNode $element, array &$idMap): array
     {
-        if (get_class($element) !== 'DOMElement') {
+        if (!$element instanceof DOMElement) {
             return ['', ''];
         }
 
@@ -133,10 +249,11 @@ class PageContent
         $existingId = $element->getAttribute('id');
         if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
             $idMap[$existingId] = true;
+
             return [$existingId, $existingId];
         }
 
-        // Create an unique id for the element
+        // Create a unique id for the element
         // Uses the content as a basis to ensure output is the same every time
         // the same content is passed through.
         $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
@@ -150,6 +267,7 @@ class PageContent
 
         $element->setAttribute('id', $newId);
         $idMap[$newId] = true;
+
         return [$existingId, $newId];
     }
 
@@ -159,15 +277,16 @@ class PageContent
     protected function toPlainText(): string
     {
         $html = $this->render(true);
+
         return html_entity_decode(strip_tags($html));
     }
 
     /**
-     * Render the page for viewing
+     * Render the page for viewing.
      */
-    public function render(bool $blankIncludes = false) : string
+    public function render(bool $blankIncludes = false): string
     {
-        $content = $this->page->html;
+        $content = $this->page->html ?? '';
 
         if (!config('app.allow_content_scripts')) {
             $content = HtmlContentFilter::removeScripts($content);
@@ -183,7 +302,7 @@ class PageContent
     }
 
     /**
-     * Parse the headers on the page to get a navigation menu
+     * Parse the headers on the page to get a navigation menu.
      */
     public function getNavigation(string $htmlContent): array
     {
@@ -191,11 +310,9 @@ class PageContent
             return [];
         }
 
-        libxml_use_internal_errors(true);
-        $doc = new DOMDocument();
-        $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
+        $doc = $this->loadDocumentFromHtml($htmlContent);
         $xPath = new DOMXPath($doc);
-        $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
+        $headers = $xPath->query('//h1|//h2|//h3|//h4|//h5|//h6');
 
         return $headers ? $this->headerNodesToLevelList($headers) : [];
     }
@@ -206,15 +323,15 @@ class PageContent
      */
     protected function headerNodesToLevelList(DOMNodeList $nodeList): array
     {
-        $tree = collect($nodeList)->map(function ($header) {
+        $tree = collect($nodeList)->map(function (DOMElement $header) {
             $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
             $text = mb_substr($text, 0, 100);
 
             return [
                 'nodeName' => strtolower($header->nodeName),
-                'level' => intval(str_replace('h', '', $header->nodeName)),
-                'link' => '#' . $header->getAttribute('id'),
-                'text' => $text,
+                'level'    => intval(str_replace('h', '', $header->nodeName)),
+                'link'     => '#' . $header->getAttribute('id'),
+                'text'     => $text,
             ];
         })->filter(function ($header) {
             return mb_strlen($header['text']) > 0;
@@ -224,6 +341,7 @@ class PageContent
         $levelChange = ($tree->pluck('level')->min() - 1);
         $tree = $tree->map(function ($header) use ($levelChange) {
             $header['level'] -= ($levelChange);
+
             return $header;
         });
 
@@ -233,7 +351,7 @@ class PageContent
     /**
      * Remove any page include tags within the given HTML.
      */
-    protected function blankPageIncludes(string $html) : string
+    protected function blankPageIncludes(string $html): string
     {
         return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
     }
@@ -241,7 +359,7 @@ class PageContent
     /**
      * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
      */
-    protected function parsePageIncludes(string $html) : string
+    protected function parsePageIncludes(string $html): string
     {
         $matches = [];
         preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
@@ -257,6 +375,7 @@ class PageContent
             }
 
             // Find page and skip this if page not found
+            /** @var ?Page $matchedPage */
             $matchedPage = Page::visible()->find($pageId);
             if ($matchedPage === null) {
                 $html = str_replace($fullMatch, '', $html);
@@ -277,16 +396,13 @@ class PageContent
         return $html;
     }
 
-
     /**
      * Fetch the content from a specific section of the given page.
      */
     protected function fetchSectionOfPage(Page $page, string $sectionId): string
     {
-        $topLevelTags = ['table', 'ul', 'ol'];
-        $doc = new DOMDocument();
-        libxml_use_internal_errors(true);
-        $doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
+        $topLevelTags = ['table', 'ul', 'ol', 'pre'];
+        $doc = $this->loadDocumentFromHtml($page->html);
 
         // Search included content for the id given and blank out if not exists.
         $matchingElem = $doc->getElementById($sectionId);
@@ -309,4 +425,17 @@ class PageContent
 
         return $innerContent;
     }
+
+    /**
+     * Create and load a DOMDocument from the given html content.
+     */
+    protected function loadDocumentFromHtml(string $html): DOMDocument
+    {
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $html = '<body>' . $html . '</body>';
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+
+        return $doc;
+    }
 }
index 79de5c827987e8d1f9a4041d70d2598839c2c87c..9981a6ed7ae2f6fa7742729f9d01ed34ddb0af68 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Models\PageRevision;
@@ -7,7 +9,6 @@ use Illuminate\Database\Eloquent\Builder;
 
 class PageEditActivity
 {
-
     protected $page;
 
     /**
@@ -20,7 +21,6 @@ class PageEditActivity
 
     /**
      * Check if there's active editing being performed on this page.
-     * @return bool
      */
     public function hasActiveEditing(): bool
     {
@@ -35,15 +35,50 @@ class PageEditActivity
         $pageDraftEdits = $this->activePageEditingQuery(60)->get();
         $count = $pageDraftEdits->count();
 
-        $userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
+        $userMessage = trans('entities.pages_draft_edit_active.start_a', ['count' => $count]);
+        if ($count === 1) {
+            /** @var PageRevision $firstDraft */
+            $firstDraft = $pageDraftEdits->first();
+            $userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
+        }
+
         $timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
+
         return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
     }
 
+    /**
+     * Get any editor clash warning messages to show for the given draft revision.
+     *
+     * @param PageRevision|Page $draft
+     *
+     * @return string[]
+     */
+    public function getWarningMessagesForDraft($draft): array
+    {
+        $warnings = [];
+
+        if ($this->hasActiveEditing()) {
+            $warnings[] = $this->activeEditingMessage();
+        }
+
+        if ($draft instanceof PageRevision && $this->hasPageBeenUpdatedSinceDraftCreated($draft)) {
+            $warnings[] = trans('entities.pages_draft_page_changed_since_creation');
+        }
+
+        return $warnings;
+    }
+
+    /**
+     * Check if the page has been updated since the draft has been saved.
+     */
+    protected function hasPageBeenUpdatedSinceDraftCreated(PageRevision $draft): bool
+    {
+        return $draft->page->updated_at->timestamp > $draft->created_at->timestamp;
+    }
+
     /**
      * Get the message to show when the user will be editing one of their drafts.
-     * @param PageRevision $draft
-     * @return string
      */
     public function getEditingActiveDraftMessage(PageRevision $draft): string
     {
@@ -51,6 +86,7 @@ class PageEditActivity
         if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
             return $message;
         }
+
         return $message . "\n" . trans('entities.pages_draft_edited_notification');
     }
 
diff --git a/app/Entities/Tools/PdfGenerator.php b/app/Entities/Tools/PdfGenerator.php
new file mode 100644 (file)
index 0000000..17a7da9
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use Barryvdh\DomPDF\Facade as DomPDF;
+use Barryvdh\Snappy\Facades\SnappyPdf;
+
+class PdfGenerator
+{
+    const ENGINE_DOMPDF = 'dompdf';
+    const ENGINE_WKHTML = 'wkhtml';
+
+    /**
+     * Generate PDF content from the given HTML content.
+     */
+    public function fromHtml(string $html): string
+    {
+        if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
+            $pdf = SnappyPDF::loadHTML($html);
+            $pdf->setOption('print-media-type', true);
+        } else {
+            $pdf = DomPDF::loadHTML($html);
+        }
+
+        return $pdf->output();
+    }
+
+    /**
+     * Get the currently active PDF engine.
+     * Returns the value of an `ENGINE_` const on this class.
+     */
+    public function getActiveEngine(): string
+    {
+        $useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
+
+        return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
+    }
+}
index 8a27ce75b6f2722aace9c279dda670267db03804..c771ee4b68926f98c271436ab9c8b4392fb1f0ab 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
@@ -9,7 +11,6 @@ use Illuminate\Support\Collection;
 
 class PermissionsUpdater
 {
-
     /**
      * Update an entities permissions from a permission form submit request.
      */
@@ -34,7 +35,7 @@ class PermissionsUpdater
         $entity->save();
         $entity->rebuildPermissions();
 
-        Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
+        Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
     }
 
     /**
@@ -60,8 +61,8 @@ class PermissionsUpdater
             return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
                 return [
                     'role_id' => $roleId,
-                    'action' => strtolower($action),
-                ] ;
+                    'action'  => strtolower($action),
+                ];
             });
         });
     }
index 81a5022ce33c4f5ccc87ac6902305bcac0c65fac..d43d982079d88262280dbe9c70b40d11632d0577 100644 (file)
-<?php namespace BookStack\Entities\Tools;
+<?php
 
+namespace BookStack\Entities\Tools;
+
+use BookStack\Actions\Tag;
 use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Models\SearchTerm;
+use DOMDocument;
+use DOMNode;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
 
 class SearchIndex
 {
     /**
-     * @var SearchTerm
+     * A list of delimiter characters used to break-up parsed content into terms for indexing.
+     *
+     * @var string
      */
-    protected $searchTerm;
+    public static $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
 
     /**
      * @var EntityProvider
      */
     protected $entityProvider;
 
-
-    public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider)
+    public function __construct(EntityProvider $entityProvider)
     {
-        $this->searchTerm = $searchTerm;
         $this->entityProvider = $entityProvider;
     }
 
-
     /**
      * Index the given entity.
      */
     public function indexEntity(Entity $entity)
     {
         $this->deleteEntityTerms($entity);
-        $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
-        $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
-        $terms = array_merge($nameTerms, $bodyTerms);
-        foreach ($terms as $index => $term) {
-            $terms[$index]['entity_type'] = $entity->getMorphClass();
-            $terms[$index]['entity_id'] = $entity->id;
-        }
-        $this->searchTerm->newQuery()->insert($terms);
+        $terms = $this->entityToTermDataArray($entity);
+        SearchTerm::query()->insert($terms);
     }
 
     /**
-     * Index multiple Entities at once
+     * Index multiple Entities at once.
+     *
      * @param Entity[] $entities
      */
-    protected function indexEntities(array $entities)
+    public function indexEntities(array $entities)
     {
         $terms = [];
         foreach ($entities as $entity) {
-            $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
-            $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
-            foreach (array_merge($nameTerms, $bodyTerms) as $term) {
-                $term['entity_id'] = $entity->id;
-                $term['entity_type'] = $entity->getMorphClass();
-                $terms[] = $term;
-            }
+            $entityTerms = $this->entityToTermDataArray($entity);
+            array_push($terms, ...$entityTerms);
         }
 
         $chunkedTerms = array_chunk($terms, 500);
         foreach ($chunkedTerms as $termChunk) {
-            $this->searchTerm->newQuery()->insert($termChunk);
+            SearchTerm::query()->insert($termChunk);
         }
     }
 
     /**
      * Delete and re-index the terms for all entities in the system.
+     * Can take a callback which is used for reporting progress.
+     * Callback receives three arguments:
+     * - An instance of the model being processed
+     * - The number that have been processed so far.
+     * - The total number of that model to be processed.
+     *
+     * @param callable(Entity, int, int):void|null $progressCallback
      */
-    public function indexAllEntities()
+    public function indexAllEntities(?callable $progressCallback = null)
     {
-        $this->searchTerm->newQuery()->truncate();
+        SearchTerm::query()->truncate();
 
         foreach ($this->entityProvider->all() as $entityModel) {
-            $selectFields = ['id', 'name', $entityModel->textField];
+            $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
+            $selectFields = ['id', 'name', $indexContentField];
+            /** @var Builder<Entity> $query */
+            $query = $entityModel->newQuery();
+            $total = $query->withTrashed()->count();
+            $chunkSize = 250;
+            $processed = 0;
+
+            $chunkCallback = function (Collection $entities) use ($progressCallback, &$processed, $total, $chunkSize, $entityModel) {
+                $this->indexEntities($entities->all());
+                $processed = min($processed + $chunkSize, $total);
+
+                if (is_callable($progressCallback)) {
+                    $progressCallback($entityModel, $processed, $total);
+                }
+            };
+
             $entityModel->newQuery()
-                ->withTrashed()
                 ->select($selectFields)
-                ->chunk(1000, function (Collection $entities) {
-                    $this->indexEntities($entities->all());
-                });
+                ->with(['tags:id,name,value,entity_id,entity_type'])
+                ->chunk($chunkSize, $chunkCallback);
         }
     }
 
@@ -91,12 +108,97 @@ class SearchIndex
     }
 
     /**
-     * Create a scored term array from the given text.
+     * Create a scored term array from the given text, where the keys are the terms
+     * and the values are their scores.
+     *
+     * @returns array<string, int>
+     */
+    protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
+    {
+        $termMap = $this->textToTermCountMap($text);
+
+        foreach ($termMap as $term => $count) {
+            $termMap[$term] = $count * $scoreAdjustment;
+        }
+
+        return $termMap;
+    }
+
+    /**
+     * Create a scored term array from the given HTML, where the keys are the terms
+     * and the values are their scores.
+     *
+     * @returns array<string, int>
+     */
+    protected function generateTermScoreMapFromHtml(string $html): array
+    {
+        if (empty($html)) {
+            return [];
+        }
+
+        $scoresByTerm = [];
+        $elementScoreAdjustmentMap = [
+            'h1' => 10,
+            'h2' => 5,
+            'h3' => 4,
+            'h4' => 3,
+            'h5' => 2,
+            'h6' => 1.5,
+        ];
+
+        $html = '<body>' . $html . '</body>';
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        /** @var DOMNode $child */
+        foreach ($topElems as $child) {
+            $nodeName = $child->nodeName;
+            $termCounts = $this->textToTermCountMap(trim($child->textContent));
+            foreach ($termCounts as $term => $count) {
+                $scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
+                $scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
+            }
+        }
+
+        return $scoresByTerm;
+    }
+
+    /**
+     * Create a scored term map from the given set of entity tags.
+     *
+     * @param Tag[] $tags
+     *
+     * @returns array<string, int>
      */
-    protected function generateTermArrayFromText(string $text, int $scoreAdjustment = 1): array
+    protected function generateTermScoreMapFromTags(array $tags): array
+    {
+        $scoreMap = [];
+        $names = [];
+        $values = [];
+
+        foreach ($tags as $tag) {
+            $names[] = $tag->name;
+            $values[] = $tag->value;
+        }
+
+        $nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
+        $valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
+
+        return $this->mergeTermScoreMaps($nameMap, $valueMap);
+    }
+
+    /**
+     * 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<string, int>
+     */
+    protected function textToTermCountMap(string $text): array
     {
         $tokenMap = []; // {TextToken => OccurrenceCount}
-        $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
+        $splitChars = static::$delimiters;
         $token = strtok($text, $splitChars);
 
         while ($token !== false) {
@@ -107,14 +209,61 @@ class SearchIndex
             $token = strtok($splitChars);
         }
 
-        $terms = [];
-        foreach ($tokenMap as $token => $count) {
-            $terms[] = [
-                'term' => $token,
-                'score' => $count * $scoreAdjustment
+        return $tokenMap;
+    }
+
+    /**
+     * 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}[]
+     */
+    protected function entityToTermDataArray(Entity $entity): array
+    {
+        $nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
+        $tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
+
+        if ($entity instanceof Page) {
+            $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
+        } else {
+            $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
+        }
+
+        $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
+
+        $dataArray = [];
+        $entityId = $entity->id;
+        $entityType = $entity->getMorphClass();
+        foreach ($mergedScoreMap as $term => $score) {
+            $dataArray[] = [
+                'term'        => $term,
+                'score'       => $score,
+                'entity_type' => $entityType,
+                'entity_id'   => $entityId,
             ];
         }
 
-        return $terms;
+        return $dataArray;
+    }
+
+    /**
+     * For the given term data arrays, Merge their contents by term
+     * while combining any scores.
+     *
+     * @param array<string, int>[] ...$scoreMaps
+     *
+     * @returns array<string, int>
+     */
+    protected function mergeTermScoreMaps(...$scoreMaps): array
+    {
+        $mergedMap = [];
+
+        foreach ($scoreMaps as $scoreMap) {
+            foreach ($scoreMap as $term => $score) {
+                $mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
+            }
+        }
+
+        return $mergedMap;
     }
 }
index 6c03c57a6105ce7a36e4c311e2b2f75ba3b13149..99271058e2306412d10d14b76a888c8ecd70d371 100644 (file)
@@ -1,10 +1,11 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use Illuminate\Http\Request;
 
 class SearchOptions
 {
-
     /**
      * @var array
      */
@@ -28,13 +29,14 @@ class SearchOptions
     /**
      * Create a new instance from a search string.
      */
-    public static function fromString(string $search): SearchOptions
+    public static function fromString(string $search): self
     {
         $decoded = static::decode($search);
-        $instance = new static();
+        $instance = new SearchOptions();
         foreach ($decoded as $type => $value) {
             $instance->$type = $value;
         }
+
         return $instance;
     }
 
@@ -43,7 +45,7 @@ class SearchOptions
      * Will look for a classic string term and use that
      * Otherwise we'll use the details from an advanced search form.
      */
-    public static function fromRequest(Request $request): SearchOptions
+    public static function fromRequest(Request $request): self
     {
         if (!$request->has('search') && !$request->has('term')) {
             return static::fromString('');
@@ -53,20 +55,28 @@ class SearchOptions
             return static::fromString($request->get('term'));
         }
 
-        $instance = new static();
+        $instance = new SearchOptions();
         $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
-        $instance->searches = explode(' ', $inputs['search'] ?? []);
-        $instance->exacts = array_filter($inputs['exact'] ?? []);
+
+        $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
+        $instance->searches = $parsedStandardTerms['terms'];
+        $instance->exacts = $parsedStandardTerms['exacts'];
+
+        array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
+
         $instance->tags = array_filter($inputs['tags'] ?? []);
+
         foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
             if (empty($filterVal)) {
                 continue;
             }
             $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
         }
+
         if (isset($inputs['types']) && count($inputs['types']) < 4) {
             $instance->filters['type'] = implode('|', $inputs['types']);
         }
+
         return $instance;
     }
 
@@ -77,15 +87,15 @@ class SearchOptions
     {
         $terms = [
             'searches' => [],
-            'exacts' => [],
-            'tags' => [],
-            'filters' => []
+            'exacts'   => [],
+            'tags'     => [],
+            'filters'  => [],
         ];
 
         $patterns = [
-            'exacts' => '/"(.*?)"/',
-            'tags' => '/\[(.*?)\]/',
-            'filters' => '/\{(.*?)\}/'
+            'exacts'  => '/"(.*?)"/',
+            'tags'    => '/\[(.*?)\]/',
+            'filters' => '/\{(.*?)\}/',
         ];
 
         // Parse special terms
@@ -99,11 +109,9 @@ class SearchOptions
         }
 
         // Parse standard terms
-        foreach (explode(' ', trim($searchString)) as $searchTerm) {
-            if ($searchTerm !== '') {
-                $terms['searches'][] = $searchTerm;
-            }
-        }
+        $parsedStandardTerms = static::parseStandardTermString($searchString);
+        array_push($terms['searches'], ...$parsedStandardTerms['terms']);
+        array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
 
         // Split filter values out
         $splitFilters = [];
@@ -116,6 +124,33 @@ class SearchOptions
         return $terms;
     }
 
+    /**
+     * Parse a standard search term string into individual search terms and
+     * extract any exact terms searches to be made.
+     *
+     * @return array{terms: array<string>, exacts: array<string>}
+     */
+    protected static function parseStandardTermString(string $termString): array
+    {
+        $terms = explode(' ', $termString);
+        $indexDelimiters = SearchIndex::$delimiters;
+        $parsed = [
+            'terms'  => [],
+            'exacts' => [],
+        ];
+
+        foreach ($terms as $searchTerm) {
+            if ($searchTerm === '') {
+                continue;
+            }
+
+            $parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
+            $parsed[$parsedList][] = $searchTerm;
+        }
+
+        return $parsed;
+    }
+
     /**
      * Encode this instance to a search string.
      */
diff --git a/app/Entities/Tools/SearchResultsFormatter.php b/app/Entities/Tools/SearchResultsFormatter.php
new file mode 100644 (file)
index 0000000..00b9c0b
--- /dev/null
@@ -0,0 +1,236 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Actions\Tag;
+use BookStack\Entities\Models\Entity;
+use Illuminate\Support\HtmlString;
+
+class SearchResultsFormatter
+{
+    /**
+     * For the given array of entities, Prepare the models to be shown in search result
+     * output. This sets a series of additional attributes.
+     *
+     * @param Entity[] $results
+     */
+    public function format(array $results, SearchOptions $options): void
+    {
+        foreach ($results as $result) {
+            $this->setSearchPreview($result, $options);
+        }
+    }
+
+    /**
+     * Update the given entity model to set attributes used for previews of the item
+     * primarily within search result lists.
+     */
+    protected function setSearchPreview(Entity $entity, SearchOptions $options)
+    {
+        $textProperty = $entity->textField;
+        $textContent = $entity->$textProperty;
+        $terms = array_merge($options->exacts, $options->searches);
+
+        $originalContentByNewAttribute = [
+            'preview_name'    => $entity->name,
+            'preview_content' => $textContent,
+        ];
+
+        foreach ($originalContentByNewAttribute as $attributeName => $content) {
+            $targetLength = ($attributeName === 'preview_name') ? 0 : 260;
+            $matchRefs = $this->getMatchPositions($content, $terms);
+            $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
+            $formatted = $this->formatTextUsingMatchPositions($mergedRefs, $content, $targetLength);
+            $entity->setAttribute($attributeName, new HtmlString($formatted));
+        }
+
+        $tags = $entity->relationLoaded('tags') ? $entity->tags->all() : [];
+        $this->highlightTagsContainingTerms($tags, $terms);
+    }
+
+    /**
+     * Highlight tags which match the given terms.
+     *
+     * @param Tag[]    $tags
+     * @param string[] $terms
+     */
+    protected function highlightTagsContainingTerms(array $tags, array $terms): void
+    {
+        foreach ($tags as $tag) {
+            $tagName = mb_strtolower($tag->name);
+            $tagValue = mb_strtolower($tag->value);
+
+            foreach ($terms as $term) {
+                $termLower = mb_strtolower($term);
+
+                if (mb_strpos($tagName, $termLower) !== false) {
+                    $tag->setAttribute('highlight_name', true);
+                }
+
+                if (mb_strpos($tagValue, $termLower) !== false) {
+                    $tag->setAttribute('highlight_value', true);
+                }
+            }
+        }
+    }
+
+    /**
+     * Get positions of the given terms within the given text.
+     * Is in the array format of [int $startIndex => int $endIndex] where the indexes
+     * are positions within the provided text.
+     *
+     * @return array<int, int>
+     */
+    protected function getMatchPositions(string $text, array $terms): array
+    {
+        $matchRefs = [];
+        $text = mb_strtolower($text);
+
+        foreach ($terms as $term) {
+            $offset = 0;
+            $term = mb_strtolower($term);
+            $pos = mb_strpos($text, $term, $offset);
+            while ($pos !== false) {
+                $end = $pos + mb_strlen($term);
+                $matchRefs[$pos] = $end;
+                $offset = $end;
+                $pos = mb_strpos($text, $term, $offset);
+            }
+        }
+
+        return $matchRefs;
+    }
+
+    /**
+     * Sort the given match positions before merging them where they're
+     * adjacent or where they overlap.
+     *
+     * @param array<int, int> $matchPositions
+     *
+     * @return array<int, int>
+     */
+    protected function sortAndMergeMatchPositions(array $matchPositions): array
+    {
+        ksort($matchPositions);
+        $mergedRefs = [];
+        $lastStart = 0;
+        $lastEnd = 0;
+
+        foreach ($matchPositions as $start => $end) {
+            if ($start > $lastEnd) {
+                $mergedRefs[$start] = $end;
+                $lastStart = $start;
+                $lastEnd = $end;
+            } elseif ($end > $lastEnd) {
+                $mergedRefs[$lastStart] = $end;
+                $lastEnd = $end;
+            }
+        }
+
+        return $mergedRefs;
+    }
+
+    /**
+     * Format the given original text, returning a version where terms are highlighted within.
+     * Returned content is in HTML text format.
+     * A given $targetLength of 0 asserts no target length limit.
+     *
+     * This is a complex function but written to be relatively efficient, going through the term matches in order
+     * so that we're only doing a one-time loop through of the matches. There is no further searching
+     * done within here.
+     */
+    protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
+    {
+        $maxEnd = mb_strlen($originalText);
+        $fetchAll = ($targetLength === 0);
+        $contextLength = ($fetchAll ? 0 : 32);
+
+        $firstStart = null;
+        $lastEnd = 0;
+        $content = '';
+        $contentTextLength = 0;
+
+        if ($fetchAll) {
+            $targetLength = $maxEnd * 2;
+        }
+
+        foreach ($matchPositions as $start => $end) {
+            // Get our outer text ranges for the added context we want to show upon the result.
+            $contextStart = max($start - $contextLength, 0, $lastEnd);
+            $contextEnd = min($end + $contextLength, $maxEnd);
+
+            // Adjust the start if we're going to be touching the previous match.
+            $startDiff = $start - $lastEnd;
+            if ($startDiff < 0) {
+                $contextStart = $start;
+                // Trims off '$startDiff' number of characters to bring it back to the start
+                // if this current match zone.
+                $content = mb_substr($content, 0, mb_strlen($content) + $startDiff);
+                $contentTextLength += $startDiff;
+            }
+
+            // Add ellipsis between results
+            if (!$fetchAll && $contextStart !== 0 && $contextStart !== $start) {
+                $content .= ' ...';
+                $contentTextLength += 4;
+            } elseif ($fetchAll) {
+                // Or fill in gap since the previous match
+                $fillLength = $contextStart - $lastEnd;
+                $content .= e(mb_substr($originalText, $lastEnd, $fillLength));
+                $contentTextLength += $fillLength;
+            }
+
+            // Add our content including the bolded matching text
+            $content .= e(mb_substr($originalText, $contextStart, $start - $contextStart));
+            $contentTextLength += $start - $contextStart;
+            $content .= '<strong>' . e(mb_substr($originalText, $start, $end - $start)) . '</strong>';
+            $contentTextLength += $end - $start;
+            $content .= e(mb_substr($originalText, $end, $contextEnd - $end));
+            $contentTextLength += $contextEnd - $end;
+
+            // Update our last end position
+            $lastEnd = $contextEnd;
+
+            // Update the first start position if it's not already been set
+            if (is_null($firstStart)) {
+                $firstStart = $contextStart;
+            }
+
+            // Stop if we're near our target
+            if ($contentTextLength >= $targetLength - 10) {
+                break;
+            }
+        }
+
+        // Just copy out the content if we haven't moved along anywhere.
+        if ($lastEnd === 0) {
+            $content = e(mb_substr($originalText, 0, $targetLength));
+            $contentTextLength = $targetLength;
+            $lastEnd = $targetLength;
+        }
+
+        // Pad out the end if we're low
+        $remainder = $targetLength - $contentTextLength;
+        if ($remainder > 10) {
+            $padEndLength = min($maxEnd - $lastEnd, $remainder);
+            $content .= e(mb_substr($originalText, $lastEnd, $padEndLength));
+            $lastEnd += $padEndLength;
+            $contentTextLength += $padEndLength;
+        }
+
+        // Pad out the start if we're still low
+        $remainder = $targetLength - $contentTextLength;
+        $firstStart = $firstStart ?: 0;
+        if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
+            $padStart = max(0, $firstStart - $remainder);
+            $content = ($padStart === 0 ? '' : '...') . e(mb_substr($originalText, $padStart, $firstStart - $padStart)) . mb_substr($content, 4);
+        }
+
+        // Add ellipsis if we're not at the end
+        if ($lastEnd < $maxEnd) {
+            $content .= '...';
+        }
+
+        return $content;
+    }
+}
index fc127f9068a49075d8cd14c2ad0e87968f96dd5b..a0a44f3a553b0bf1791afc08645da769e59e70d8 100644 (file)
@@ -1,53 +1,64 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\User;
 use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\SearchTerm;
 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;
+use SplObjectStorage;
 
 class SearchRunner
 {
-
     /**
      * @var EntityProvider
      */
     protected $entityProvider;
 
-    /**
-     * @var Connection
-     */
-    protected $db;
-
     /**
      * @var PermissionService
      */
     protected $permissionService;
 
-
     /**
-     * Acceptable operators to be used in a query
+     * Acceptable operators to be used in a query.
+     *
      * @var array
      */
     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
 
+    /**
+     * Retain a cache of score adjusted terms for specific search options.
+     * From PHP>=8 this can be made into a WeakMap instead.
+     *
+     * @var SplObjectStorage
+     */
+    protected $termAdjustmentCache;
 
-    public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
+    public function __construct(EntityProvider $entityProvider, PermissionService $permissionService)
     {
         $this->entityProvider = $entityProvider;
-        $this->db = $db;
         $this->permissionService = $permissionService;
+        $this->termAdjustmentCache = new SplObjectStorage();
     }
 
     /**
      * Search all entities in the system.
      * The provided count is for each entity to search,
-     * Total returned could can be larger and not guaranteed.
+     * Total returned could be larger and not guaranteed.
+     *
+     * @return array{total: int, count: int, has_more: bool, results: Entity[]}
      */
     public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
     {
@@ -56,7 +67,7 @@ class SearchRunner
 
         if ($entityType !== 'all') {
             $entityTypesToSearch = $entityType;
-        } else if (isset($searchOpts->filters['type'])) {
+        } elseif (isset($searchOpts->filters['type'])) {
             $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
         }
 
@@ -68,26 +79,30 @@ class SearchRunner
             if (!in_array($entityType, $entityTypes)) {
                 continue;
             }
-            $search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
-            $entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
-            if ($entityTotal > $page * $count) {
+
+            $entityModelInstance = $this->entityProvider->get($entityType);
+            $searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
+            $entityTotal = $searchQuery->count();
+            $searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
+
+            if ($entityTotal > ($page * $count)) {
                 $hasMore = true;
             }
+
             $total += $entityTotal;
-            $results = $results->merge($search);
+            $results = $results->merge($searchResults);
         }
 
         return [
-            'total' => $total,
-            'count' => count($results),
+            'total'    => $total,
+            'count'    => count($results),
             'has_more' => $hasMore,
-            'results' => $results->sortByDesc('score')->values(),
+            'results'  => $results->sortByDesc('score')->values(),
         ];
     }
 
-
     /**
-     * Search a book for entities
+     * Search a book for entities.
      */
     public function searchBook(int $bookId, string $searchString): Collection
     {
@@ -100,7 +115,9 @@ class SearchRunner
             if (!in_array($entityType, $entityTypes)) {
                 continue;
             }
-            $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
+
+            $entityModelInstance = $this->entityProvider->get($entityType);
+            $search = $this->buildQuery($opts, $entityModelInstance)->where('book_id', '=', $bookId)->take(20)->get();
             $results = $results->merge($search);
         }
 
@@ -108,78 +125,204 @@ class SearchRunner
     }
 
     /**
-     * Search a chapter for entities
+     * Search a chapter for entities.
      */
     public function searchChapter(int $chapterId, string $searchString): Collection
     {
         $opts = SearchOptions::fromString($searchString);
-        $pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
+        $entityModelInstance = $this->entityProvider->get('page');
+        $pages = $this->buildQuery($opts, $entityModelInstance)->where('chapter_id', '=', $chapterId)->take(20)->get();
+
         return $pages->sortByDesc('score');
     }
 
     /**
-     * Search across a particular entity type.
-     * Setting getCount = true will return the total
-     * matching instead of the items themselves.
-     * @return \Illuminate\Database\Eloquent\Collection|int|static[]
+     * Get a page of result data from the given query based on the provided page parameters.
      */
-    protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
+    protected function getPageOfDataFromQuery(EloquentBuilder $query, Entity $entityModelInstance, int $page = 1, int $count = 20): EloquentCollection
     {
-        $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
-        if ($getCount) {
-            return $query->count();
+        $relations = ['tags'];
+
+        if ($entityModelInstance instanceof BookChild) {
+            $relations['book'] = function (BelongsTo $query) {
+                $query->scopes('visible');
+            };
+        }
+
+        if ($entityModelInstance instanceof Page) {
+            $relations['chapter'] = function (BelongsTo $query) {
+                $query->scopes('visible');
+            };
         }
 
-        $query = $query->skip(($page-1) * $count)->take($count);
-        return $query->get();
+        return $query->clone()
+            ->with(array_filter($relations))
+            ->skip(($page - 1) * $count)
+            ->take($count)
+            ->get();
     }
 
     /**
-     * Create a search query for an entity
+     * Create a search query for an entity.
      */
-    protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
+    protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance, string $action = 'view'): EloquentBuilder
     {
-        $entity = $this->entityProvider->get($entityType);
-        $entitySelect = $entity->newQuery();
+        $entityQuery = $entityModelInstance->newQuery();
 
-        // Handle normal search terms
-        if (count($searchOpts->searches) > 0) {
-            $rawScoreSum = $this->db->raw('SUM(score) as score');
-            $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', $rawScoreSum);
-            $subQuery->where('entity_type', '=', $entity->getMorphClass());
-            $subQuery->where(function (Builder $query) use ($searchOpts) {
-                foreach ($searchOpts->searches as $inputTerm) {
-                    $query->orWhere('term', 'like', $inputTerm .'%');
-                }
-            })->groupBy('entity_type', 'entity_id');
-            $entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
-                $join->on('id', '=', 'entity_id');
-            })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
-            $entitySelect->mergeBindings($subQuery);
+        if ($entityModelInstance instanceof Page) {
+            $entityQuery->select($entityModelInstance::$listAttributes);
+        } else {
+            $entityQuery->select(['*']);
         }
 
+        // Handle normal search terms
+        $this->applyTermSearch($entityQuery, $searchOpts, $entityModelInstance);
+
         // Handle exact term matching
         foreach ($searchOpts->exacts as $inputTerm) {
-            $entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
-                $query->where('name', 'like', '%'.$inputTerm .'%')
-                    ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
+            $entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) {
+                $query->where('name', 'like', '%' . $inputTerm . '%')
+                    ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
             });
         }
 
         // Handle tag searches
         foreach ($searchOpts->tags as $inputTerm) {
-            $this->applyTagSearch($entitySelect, $inputTerm);
+            $this->applyTagSearch($entityQuery, $inputTerm);
         }
 
         // Handle filters
         foreach ($searchOpts->filters as $filterTerm => $filterValue) {
             $functionName = Str::camel('filter_' . $filterTerm);
             if (method_exists($this, $functionName)) {
-                $this->$functionName($entitySelect, $entity, $filterValue);
+                $this->$functionName($entityQuery, $entityModelInstance, $filterValue);
             }
         }
 
-        return $this->permissionService->enforceEntityRestrictions($entity, $entitySelect, $action);
+        return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
+    }
+
+    /**
+     * For the given search query, apply the queries for handling the regular search terms.
+     */
+    protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, Entity $entity): void
+    {
+        $terms = $options->searches;
+        if (count($terms) === 0) {
+            return;
+        }
+
+        $scoredTerms = $this->getTermAdjustments($options);
+        $scoreSelect = $this->selectForScoredTerms($scoredTerms);
+
+        $subQuery = DB::table('search_terms')->select([
+            'entity_id',
+            'entity_type',
+            DB::raw($scoreSelect['statement']),
+        ]);
+
+        $subQuery->addBinding($scoreSelect['bindings'], 'select');
+
+        $subQuery->where('entity_type', '=', $entity->getMorphClass());
+        $subQuery->where(function (Builder $query) use ($terms) {
+            foreach ($terms as $inputTerm) {
+                $query->orWhere('term', 'like', $inputTerm . '%');
+            }
+        });
+        $subQuery->groupBy('entity_type', 'entity_id');
+
+        $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
+        $entityQuery->addSelect('s.score');
+        $entityQuery->orderBy('score', 'desc');
+    }
+
+    /**
+     * Create a select statement, with prepared bindings, for the given
+     * set of scored search terms.
+     *
+     * @param array<string, float> $scoredTerms
+     *
+     * @return array{statement: string, bindings: string[]}
+     */
+    protected function selectForScoredTerms(array $scoredTerms): array
+    {
+        // Within this we walk backwards to create the chain of 'if' statements
+        // so that each previous statement is used in the 'else' condition of
+        // the next (earlier) to be built. We start at '0' to have no score
+        // on no match (Should never actually get to this case).
+        $ifChain = '0';
+        $bindings = [];
+        foreach ($scoredTerms as $term => $score) {
+            $ifChain = 'IF(term like ?, score * ' . (float) $score . ', ' . $ifChain . ')';
+            $bindings[] = $term . '%';
+        }
+
+        return [
+            'statement' => 'SUM(' . $ifChain . ') as score',
+            'bindings'  => array_reverse($bindings),
+        ];
+    }
+
+    /**
+     * For the terms in the given search options, query their popularity across all
+     * search terms then provide that back as score adjustment multiplier applicable
+     * for their rarity. Returns an array of float multipliers, keyed by term.
+     *
+     * @return array<string, float>
+     */
+    protected function getTermAdjustments(SearchOptions $options): array
+    {
+        if (isset($this->termAdjustmentCache[$options])) {
+            return $this->termAdjustmentCache[$options];
+        }
+
+        $termQuery = SearchTerm::query()->toBase();
+        $whenStatements = [];
+        $whenBindings = [];
+
+        foreach ($options->searches as $term) {
+            $whenStatements[] = 'WHEN term LIKE ? THEN ?';
+            $whenBindings[] = $term . '%';
+            $whenBindings[] = $term;
+
+            $termQuery->orWhere('term', 'like', $term . '%');
+        }
+
+        $case = 'CASE ' . implode(' ', $whenStatements) . ' END';
+        $termQuery->selectRaw($case . ' as term', $whenBindings);
+        $termQuery->selectRaw('COUNT(*) as count');
+        $termQuery->groupByRaw($case, $whenBindings);
+
+        $termCounts = $termQuery->pluck('count', 'term')->toArray();
+        $adjusted = $this->rawTermCountsToAdjustments($termCounts);
+
+        $this->termAdjustmentCache[$options] = $adjusted;
+
+        return $this->termAdjustmentCache[$options];
+    }
+
+    /**
+     * Convert counts of terms into a relative-count normalised multiplier.
+     *
+     * @param array<string, int> $termCounts
+     *
+     * @return array<string, int>
+     */
+    protected function rawTermCountsToAdjustments(array $termCounts): array
+    {
+        if (empty($termCounts)) {
+            return [];
+        }
+
+        $multipliers = [];
+        $max = max(array_values($termCounts));
+
+        foreach ($termCounts as $term => $count) {
+            $percent = round($count / $max, 5);
+            $multipliers[$term] = 1.3 - $percent;
+        }
+
+        return $multipliers;
     }
 
     /**
@@ -191,7 +334,8 @@ class SearchRunner
         foreach ($this->queryOperators as $operator) {
             $escapedOperators[] = preg_quote($operator);
         }
-        return join('|', $escapedOperators);
+
+        return implode('|', $escapedOperators);
     }
 
     /**
@@ -199,7 +343,7 @@ class SearchRunner
      */
     protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
     {
-        preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
+        preg_match('/^(.*?)((' . $this->getRegexEscapedOperators() . ')(.*?))?$/', $tagTerm, $tagSplit);
         $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
             $tagName = $tagSplit[1];
             $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
@@ -213,7 +357,9 @@ class SearchRunner
                     // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
                     // search the value as a string which prevents being able to do number-based operations
                     // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
-                    $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
+                    /** @var Connection $connection */
+                    $connection = $query->getConnection();
+                    $tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
                     $query->whereRaw("value ${tagOperator} ${tagValue}");
                 } else {
                     $query->where('value', $tagOperator, $tagValue);
@@ -222,51 +368,47 @@ class SearchRunner
                 $query->where('name', '=', $tagName);
             }
         });
+
         return $query;
     }
 
     /**
-     * Custom entity search filters
+     * Custom entity search filters.
      */
-
-    protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
+    protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void
     {
         try {
             $date = date_create($input);
+            $query->where('updated_at', '>=', $date);
         } catch (\Exception $e) {
-            return;
         }
-        $query->where('updated_at', '>=', $date);
     }
 
-    protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input)
+    protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
     {
         try {
             $date = date_create($input);
+            $query->where('updated_at', '<', $date);
         } catch (\Exception $e) {
-            return;
         }
-        $query->where('updated_at', '<', $date);
     }
 
-    protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input)
+    protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
     {
         try {
             $date = date_create($input);
+            $query->where('created_at', '>=', $date);
         } catch (\Exception $e) {
-            return;
         }
-        $query->where('created_at', '>=', $date);
     }
 
     protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
     {
         try {
             $date = date_create($input);
+            $query->where('created_at', '<', $date);
         } catch (\Exception $e) {
-            return;
         }
-        $query->where('created_at', '<', $date);
     }
 
     protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
@@ -298,7 +440,7 @@ class SearchRunner
 
     protected function filterInName(EloquentBuilder $query, Entity $model, $input)
     {
-        $query->where('name', 'like', '%' .$input. '%');
+        $query->where('name', 'like', '%' . $input . '%');
     }
 
     protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
@@ -308,7 +450,7 @@ class SearchRunner
 
     protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
     {
-        $query->where($model->textField, 'like', '%' .$input. '%');
+        $query->where($model->textField, 'like', '%' . $input . '%');
     }
 
     protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
@@ -338,16 +480,14 @@ class SearchRunner
         }
     }
 
-
     /**
-     * Sorting filter options
+     * Sorting filter options.
      */
-
     protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
     {
-        $commentsTable = $this->db->getTablePrefix() . 'comments';
+        $commentsTable = DB::getTablePrefix() . 'comments';
         $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
-        $commentQuery = $this->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.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');
 
         $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
     }
index f3849bbb4741e8c075c122bca3274f242f6d0e28..50d7981716e68c7dd662e982e22b115d64a0b4f2 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
index 6964fa2e62419e111531cfbb006c008cfc6fb8a0..617ef4a620df810f9369e8fd8f6e49abed461304 100644 (file)
@@ -1,35 +1,38 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use Illuminate\Support\Collection;
 
 class SiblingFetcher
 {
-
     /**
      * Search among the siblings of the entity of given type and id.
      */
     public function fetch(string $entityType, int $entityId): Collection
     {
-        $entity = (new EntityProvider)->get($entityType)->visible()->findOrFail($entityId);
+        $entity = (new EntityProvider())->get($entityType)->visible()->findOrFail($entityId);
         $entities = [];
 
         // Page in chapter
-        if ($entity->isA('page') && $entity->chapter) {
+        if ($entity instanceof Page && $entity->chapter) {
             $entities = $entity->chapter->getVisiblePages();
         }
 
         // Page in book or chapter
-        if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
+        if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
             $entities = $entity->book->getDirectChildren();
         }
 
         // Book
         // Gets just the books in a shelf if shelf is in context
-        if ($entity->isA('book')) {
-            $contextShelf = (new ShelfContext)->getContextualShelfForBook($entity);
+        if ($entity instanceof Book) {
+            $contextShelf = (new ShelfContext())->getContextualShelfForBook($entity);
             if ($contextShelf) {
                 $entities = $contextShelf->visibleBooks()->get();
             } else {
@@ -37,8 +40,8 @@ class SiblingFetcher
             }
         }
 
-        // Shelve
-        if ($entity->isA('bookshelf')) {
+        // Shelf
+        if ($entity instanceof Bookshelf) {
             $entities = Bookshelf::visible()->get();
         }
 
index 4501279f2a31d3c367e6a2ce34be9f1afedaf639..9fd9036adefdc84661fa28926212edacd8a83021 100644 (file)
@@ -1,15 +1,17 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Entities\Models\BookChild;
 use BookStack\Interfaces\Sluggable;
+use BookStack\Model;
 use Illuminate\Support\Str;
 
 class SlugGenerator
 {
-
     /**
      * Generate a fresh slug for the given entity.
-     * The slug will generated so it does not conflict within the same parent item.
+     * The slug will be generated so that it doesn't conflict within the same parent item.
      */
     public function generate(Sluggable $model): string
     {
@@ -17,6 +19,7 @@ class SlugGenerator
         while ($this->slugInUse($slug, $model)) {
             $slug .= '-' . Str::random(3);
         }
+
         return $slug;
     }
 
@@ -26,15 +29,18 @@ class SlugGenerator
     protected function formatNameAsSlug(string $name): string
     {
         $slug = Str::slug($name);
-        if ($slug === "") {
+        if ($slug === '') {
             $slug = substr(md5(rand(1, 500)), 0, 5);
         }
+
         return $slug;
     }
 
     /**
      * 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
     {
index df98fd318fd89e1b0d82b897cbcd038a97a4e1a3..1e130c9e17956cc8ecaf8db05a4d3d9807d5c67b 100644 (file)
@@ -1,11 +1,13 @@
-<?php namespace BookStack\Entities\Tools;
+<?php
 
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Entity;
-use BookStack\Entities\EntityProvider;
 use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\NotifyException;
@@ -13,26 +15,31 @@ use BookStack\Facades\Activity;
 use BookStack\Uploads\AttachmentService;
 use BookStack\Uploads\ImageService;
 use Exception;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Carbon;
 
 class TrashCan
 {
-
     /**
      * Send a shelf to the recycle bin.
+     *
+     * @throws NotifyException
      */
     public function softDestroyShelf(Bookshelf $shelf)
     {
+        $this->ensureDeletable($shelf);
         Deletion::createForEntity($shelf);
         $shelf->delete();
     }
 
     /**
      * Send a book to the recycle bin.
+     *
      * @throws Exception
      */
     public function softDestroyBook(Book $book)
     {
+        $this->ensureDeletable($book);
         Deletion::createForEntity($book);
 
         foreach ($book->pages as $page) {
@@ -48,11 +55,13 @@ class TrashCan
 
     /**
      * Send a chapter to the recycle bin.
+     *
      * @throws Exception
      */
     public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
     {
         if ($recordDelete) {
+            $this->ensureDeletable($chapter);
             Deletion::createForEntity($chapter);
         }
 
@@ -67,40 +76,72 @@ class TrashCan
 
     /**
      * Send a page to the recycle bin.
+     *
      * @throws Exception
      */
     public function softDestroyPage(Page $page, bool $recordDelete = true)
     {
         if ($recordDelete) {
+            $this->ensureDeletable($page);
             Deletion::createForEntity($page);
         }
 
-        // Check if set as custom homepage & remove setting if not used or throw error if active
-        $customHome = setting('app-homepage', '0:');
-        if (intval($page->id) === intval(explode(':', $customHome)[0])) {
-            if (setting('app-homepage-type') === 'page') {
-                throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
+        $page->delete();
+    }
+
+    /**
+     * Ensure the given entity is deletable.
+     * Is not for permissions, but logical conditions within the application.
+     * Will throw if not deletable.
+     *
+     * @throws NotifyException
+     */
+    protected function ensureDeletable(Entity $entity): void
+    {
+        $customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
+        $customHomeActive = setting('app-homepage-type') === 'page';
+        $removeCustomHome = false;
+
+        // Check custom homepage usage for pages
+        if ($entity instanceof Page && $entity->id === $customHomeId) {
+            if ($customHomeActive) {
+                throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
+            }
+            $removeCustomHome = true;
+        }
+
+        // Check custom homepage usage within chapters or books
+        if ($entity instanceof Chapter || $entity instanceof Book) {
+            if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
+                if ($customHomeActive) {
+                    throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
+                }
+                $removeCustomHome = true;
             }
-            setting()->remove('app-homepage');
         }
 
-        $page->delete();
+        if ($removeCustomHome) {
+            setting()->remove('app-homepage');
+        }
     }
 
     /**
      * Remove a bookshelf from the system.
+     *
      * @throws Exception
      */
     protected function destroyShelf(Bookshelf $shelf): int
     {
         $this->destroyCommonRelations($shelf);
         $shelf->forceDelete();
+
         return 1;
     }
 
     /**
      * Remove a book from the system.
      * Destroys any child chapters and pages.
+     *
      * @throws Exception
      */
     protected function destroyBook(Book $book): int
@@ -120,37 +161,40 @@ class TrashCan
 
         $this->destroyCommonRelations($book);
         $book->forceDelete();
+
         return $count + 1;
     }
 
     /**
      * Remove a chapter from the system.
      * Destroys all pages within.
+     *
      * @throws Exception
      */
     protected function destroyChapter(Chapter $chapter): int
     {
         $count = 0;
         $pages = $chapter->pages()->withTrashed()->get();
-        if (count($pages)) {
-            foreach ($pages as $page) {
-                $this->destroyPage($page);
-                $count++;
-            }
+        foreach ($pages as $page) {
+            $this->destroyPage($page);
+            $count++;
         }
 
         $this->destroyCommonRelations($chapter);
         $chapter->forceDelete();
+
         return $count + 1;
     }
 
     /**
      * Remove a page from the system.
+     *
      * @throws Exception
      */
     protected function destroyPage(Page $page): int
     {
         $this->destroyCommonRelations($page);
+        $page->allRevisions()->delete();
 
         // Delete Attached Files
         $attachmentService = app(AttachmentService::class);
@@ -159,6 +203,7 @@ class TrashCan
         }
 
         $page->forceDelete();
+
         return 1;
     }
 
@@ -170,9 +215,10 @@ class TrashCan
     {
         $counts = [];
 
-        /** @var Entity $instance */
-        foreach ((new EntityProvider)->all() as $key => $instance) {
-            $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
+        foreach ((new EntityProvider())->all() as $key => $instance) {
+            /** @var Builder<Entity> $query */
+            $query = $instance->newQuery();
+            $counts[$key] = $query->onlyTrashed()->count();
         }
 
         return $counts;
@@ -180,6 +226,7 @@ class TrashCan
 
     /**
      * Destroy all items that have pending deletions.
+     *
      * @throws Exception
      */
     public function empty(): int
@@ -189,11 +236,13 @@ class TrashCan
         foreach ($deletions as $deletion) {
             $deleteCount += $this->destroyFromDeletion($deletion);
         }
+
         return $deleteCount;
     }
 
     /**
      * Destroy an element from the given deletion model.
+     *
      * @throws Exception
      */
     public function destroyFromDeletion(Deletion $deletion): int
@@ -206,28 +255,33 @@ class TrashCan
             $count = $this->destroyEntity($deletion->deletable);
         }
         $deletion->delete();
+
         return $count;
     }
 
     /**
      * Restore the content within the given deletion.
+     *
      * @throws Exception
      */
     public function restoreFromDeletion(Deletion $deletion): int
     {
         $shouldRestore = true;
         $restoreCount = 0;
-        $parent = $deletion->deletable->getParent();
 
-        if ($parent && $parent->trashed()) {
-            $shouldRestore = false;
+        if ($deletion->deletable instanceof Entity) {
+            $parent = $deletion->deletable->getParent();
+            if ($parent && $parent->trashed()) {
+                $shouldRestore = false;
+            }
         }
 
-        if ($shouldRestore) {
+        if ($deletion->deletable instanceof Entity && $shouldRestore) {
             $restoreCount = $this->restoreEntity($deletion->deletable);
         }
 
         $deletion->delete();
+
         return $restoreCount;
     }
 
@@ -235,6 +289,7 @@ class TrashCan
      * Automatically clear old content from the recycle bin
      * depending on the configured lifetime.
      * Returns the total number of deleted elements.
+     *
      * @throws Exception
      */
     public function autoClearOld(): int
@@ -286,6 +341,7 @@ class TrashCan
 
     /**
      * Destroy the given entity.
+     *
      * @throws Exception
      */
     protected function destroyEntity(Entity $entity): int
@@ -302,6 +358,8 @@ class TrashCan
         if ($entity instanceof Bookshelf) {
             return $this->destroyShelf($entity);
         }
+
+        return 0;
     }
 
     /**
@@ -317,10 +375,11 @@ class TrashCan
         $entity->jointPermissions()->delete();
         $entity->searchTerms()->delete();
         $entity->deletions()->delete();
+        $entity->favourites()->delete();
 
-        if ($entity instanceof HasCoverImage && $entity->cover) {
+        if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
             $imageService = app()->make(ImageService::class);
-            $imageService->destroy($entity->cover);
+            $imageService->destroy($entity->cover()->first());
         }
     }
 }
index 36ea8be9de5dc8b2bbe3232483b25c3da68ad491..360370de4109e723ed3446d5ff9de901fcc80556 100644 (file)
@@ -4,5 +4,4 @@ namespace BookStack\Exceptions;
 
 class ApiAuthException extends UnauthorizedException
 {
-
 }
index 71407b3c0f1fe95167f2b272e266ae13c46840af..c39d4f592338add9044763811f48f923af30f878 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class ConfirmationEmailException extends NotifyException
 {
-
 }
index f5f0971f2aadf42517724e2c5bc4bf67639eb7d0..1182a758ec25f1b93676fc321657f49f8fe9816b 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class FileUploadException extends PrettyException
 {
-
 }
index 19689716431bcaa2780a79ba089fab2f508e17a7..7ec502525091f487b3a822df9d983a963bf5fde2 100644 (file)
@@ -4,12 +4,13 @@ namespace BookStack\Exceptions;
 
 use Exception;
 use Illuminate\Auth\AuthenticationException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Symfony\Component\HttpKernel\Exception\HttpException;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Throwable;
 
 class Handler extends ExceptionHandler
 {
@@ -28,6 +29,7 @@ class Handler extends ExceptionHandler
      * @var array
      */
     protected $dontFlash = [
+        'current_password',
         'password',
         'password_confirmation',
     ];
@@ -35,12 +37,13 @@ class Handler extends ExceptionHandler
     /**
      * Report or log an exception.
      *
-     * @param Exception $exception
-     * @return void
+     * @param \Throwable $exception
+     *
+     * @throws \Throwable
      *
-     * @throws Exception
+     * @return void
      */
-    public function report(Exception $exception)
+    public function report(Throwable $exception)
     {
         parent::report($exception);
     }
@@ -48,39 +51,17 @@ class Handler extends ExceptionHandler
     /**
      * Render an exception into an HTTP response.
      *
-     * @param  \Illuminate\Http\Request $request
-     * @param Exception $e
+     * @param \Illuminate\Http\Request $request
+     * @param Exception                $e
+     *
      * @return \Illuminate\Http\Response
      */
-    public function render($request, Exception $e)
+    public function render($request, Throwable $e)
     {
         if ($this->isApiRequest($request)) {
             return $this->renderApiException($e);
         }
 
-        // Handle notify exceptions which will redirect to the
-        // specified location then show a notification message.
-        if ($this->isExceptionType($e, NotifyException::class)) {
-            $message = $this->getOriginalMessage($e);
-            if (!empty($message)) {
-                session()->flash('error', $message);
-            }
-            return redirect($e->redirectLocation);
-        }
-
-        // Handle pretty exceptions which will show a friendly application-fitting page
-        // Which will include the basic message to point the user roughly to the cause.
-        if ($this->isExceptionType($e, PrettyException::class)  && !config('app.debug')) {
-            $message = $this->getOriginalMessage($e);
-            $code = ($e->getCode() === 0) ? 500 : $e->getCode();
-            return response()->view('errors/' . $code, ['message' => $message], $code);
-        }
-
-        // Handle 404 errors with a loaded session to enable showing user-specific information
-        if ($this->isExceptionType($e, NotFoundHttpException::class)) {
-            return \Route::respondWithRoute('fallback');
-        }
-
         return parent::render($request, $e);
     }
 
@@ -95,19 +76,24 @@ class Handler extends ExceptionHandler
     /**
      * Render an exception when the API is in use.
      */
-    protected function renderApiException(Exception $e): JsonResponse
+    protected function renderApiException(Throwable $e): JsonResponse
     {
-        $code = $e->getCode() === 0 ? 500 : $e->getCode();
+        $code = 500;
         $headers = [];
+
         if ($e instanceof HttpException) {
             $code = $e->getStatusCode();
             $headers = $e->getHeaders();
         }
 
+        if ($e instanceof ModelNotFoundException) {
+            $code = 404;
+        }
+
         $responseData = [
             'error' => [
                 'message' => $e->getMessage(),
-            ]
+            ],
         ];
 
         if ($e instanceof ValidationException) {
@@ -116,38 +102,16 @@ class Handler extends ExceptionHandler
         }
 
         $responseData['error']['code'] = $code;
-        return new JsonResponse($responseData, $code, $headers);
-    }
-
-    /**
-     * Check the exception chain to compare against the original exception type.
-     */
-    protected function isExceptionType(Exception $e, string $type): bool
-    {
-        do {
-            if (is_a($e, $type)) {
-                return true;
-            }
-        } while ($e = $e->getPrevious());
-        return false;
-    }
 
-    /**
-     * Get original exception message.
-     */
-    protected function getOriginalMessage(Exception $e): string
-    {
-        do {
-            $message = $e->getMessage();
-        } while ($e = $e->getPrevious());
-        return $message;
+        return new JsonResponse($responseData, $code, $headers);
     }
 
     /**
      * Convert an authentication exception into an unauthenticated response.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \Illuminate\Auth\AuthenticationException  $exception
+     * @param \Illuminate\Http\Request                 $request
+     * @param \Illuminate\Auth\AuthenticationException $exception
+     *
      * @return \Illuminate\Http\Response
      */
     protected function unauthenticated($request, AuthenticationException $exception)
@@ -162,8 +126,9 @@ class Handler extends ExceptionHandler
     /**
      * Convert a validation exception into a JSON response.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \Illuminate\Validation\ValidationException  $exception
+     * @param \Illuminate\Http\Request                   $request
+     * @param \Illuminate\Validation\ValidationException $exception
+     *
      * @return \Illuminate\Http\JsonResponse
      */
     protected function invalidJson($request, ValidationException $exception)
index 2a34bbc626b7c91c44b1bd8d17f6e39db4fcb5df..4ad45d92a35a8449f35e8c8f8ee8db44ce17f4df 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 use Exception;
 
index c64dddaa2b16356f42eeb4d2c0b8d2cb181c4f47..fbc1624226f09d793adc6959357442c4b64e5b40 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class ImageUploadException extends PrettyException
 {
-
 }
index e155579d601366292cdc88e5e7ae106c4e38949f..e037fcb8e9041dd7bcb4f8115af350b299cb148d 100644 (file)
@@ -1,10 +1,11 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 use Exception;
 
 class JsonDebugException extends Exception
 {
-
     protected $data;
 
     /**
index f95e991d3c5bec2f350d1e6cb0c3cc8bf0dd0fb9..6383f68bd4f641bbe83787088edf8726276242cf 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class LdapException extends PrettyException
 {
-
 }
index 7ffb0c0658c959214e26010f9fe25485805a1b81..e4cb62c86ef4ff124254b018d21fa7f77dab2f6b 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class LoginAttemptEmailNeededException extends LoginAttemptException
 {
-
 }
index 22cc980a01757f623dea76bbaa7c216efa0602d9..1dd28d3ea29d3dfd7cfd53cb758fdeda3b14accf 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class LoginAttemptException extends \Exception
 {
-
 }
index c237dfad3b4c1530877cc4dd10ccbb6017c379f8..47e0e7aa2226ba18e308434d146a92d3034a76b3 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 use Exception;
 
 class MoveOperationException extends Exception
 {
-
 }
index 94a4054152b6e9a1c3ad1e78d622ad1b0136eac0..016ee597f5528adeed9e40095bb3138777a30de0 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class NotFoundException extends PrettyException
 {
-
     /**
      * NotFoundException constructor.
      */
index 4f810596099cf2132093677b571ce40bf44a5aa8..8e748a21dc77ed17301610d6f6e1f877de4ac53e 100644 (file)
@@ -1,18 +1,38 @@
-<?php namespace BookStack\Exceptions;
+<?php
 
-class NotifyException extends \Exception
-{
+namespace BookStack\Exceptions;
+
+use Exception;
+use Illuminate\Contracts\Support\Responsable;
 
+class NotifyException extends Exception implements Responsable
+{
     public $message;
     public $redirectLocation;
 
     /**
      * NotifyException constructor.
      */
-    public function __construct(string $message, string $redirectLocation = "/")
+    public function __construct(string $message, string $redirectLocation = '/')
     {
         $this->message = $message;
         $this->redirectLocation = $redirectLocation;
         parent::__construct();
     }
+
+    /**
+     * Send the response for this type of exception.
+     *
+     * {@inheritdoc}
+     */
+    public function toResponse($request)
+    {
+        $message = $this->getMessage();
+
+        if (!empty($message)) {
+            session()->flash('error', $message);
+        }
+
+        return redirect($this->redirectLocation);
+    }
 }
diff --git a/app/Exceptions/OpenIdConnectException.php b/app/Exceptions/OpenIdConnectException.php
new file mode 100644 (file)
index 0000000..7bbc4bd
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+class OpenIdConnectException extends NotifyException
+{
+}
index e2a8c53b4bcd927ff912495c16f013162866dc1b..64da55d21f4df08c153a049aef207c46575ba3d6 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 use Exception;
 
 class PermissionsException extends Exception
 {
-
 }
index 7fad7df45812e1fc2e75881e50af063b07c23ff5..f446442d058dbcb7e383879208c30ea239970f5c 100644 (file)
@@ -1,6 +1,49 @@
-<?php namespace BookStack\Exceptions;
+<?php
 
-class PrettyException extends \Exception
+namespace BookStack\Exceptions;
+
+use Exception;
+use Illuminate\Contracts\Support\Responsable;
+
+class PrettyException extends Exception implements Responsable
 {
+    /**
+     * @var ?string
+     */
+    protected $subtitle = null;
+
+    /**
+     * @var ?string
+     */
+    protected $details = null;
+
+    /**
+     * Render a response for when this exception occurs.
+     *
+     * {@inheritdoc}
+     */
+    public function toResponse($request)
+    {
+        $code = ($this->getCode() === 0) ? 500 : $this->getCode();
+
+        return response()->view('errors.' . $code, [
+            'message'  => $this->getMessage(),
+            'subtitle' => $this->subtitle,
+            'details'  => $this->details,
+        ], $code);
+    }
+
+    public function setSubtitle(string $subtitle): self
+    {
+        $this->subtitle = $subtitle;
+
+        return $this;
+    }
+
+    public function setDetails(string $details): self
+    {
+        $this->details = $details;
 
+        return $this;
+    }
 }
index 13db23f27bbd02ddb7fe5479f7892f1e9d4038c8..417fe1c663dc970cab051d8aebfd45adcb484eb4 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class SamlException extends NotifyException
 {
-
 }
index 1a5984c12df7b8a2967a455109e94433370314fb..c6f182b5556eadf9064855d9ec222b4290567bd9 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class SocialDriverNotConfigured extends PrettyException
 {
-
 }
index 7eaa72bd55c50f5dced1cf6e9ddad862d49220bc..77701528aaea7963342c233cba5c8d8dce903340 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class SocialSignInAccountNotUsed extends SocialSignInException
 {
-
 }
index 734b46e5f501537d964de82c1e634087ceeea02a..859d23cc3ef00ff227548d9de9da8e5f70fb6fe9 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class SocialSignInException extends NotifyException
 {
-
 }
diff --git a/app/Exceptions/SortOperationException.php b/app/Exceptions/SortOperationException.php
deleted file mode 100644 (file)
index 8f91217..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<?php namespace BookStack\Exceptions;
-
-use Exception;
-
-class SortOperationException extends Exception
-{
-
-}
diff --git a/app/Exceptions/StoppedAuthenticationException.php b/app/Exceptions/StoppedAuthenticationException.php
new file mode 100644 (file)
index 0000000..b9aadb0
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\User;
+use Illuminate\Contracts\Support\Responsable;
+use Illuminate\Http\Request;
+
+class StoppedAuthenticationException extends \Exception implements Responsable
+{
+    protected $user;
+    protected $loginService;
+
+    /**
+     * StoppedAuthenticationException constructor.
+     */
+    public function __construct(User $user, LoginService $loginService)
+    {
+        $this->user = $user;
+        $this->loginService = $loginService;
+        parent::__construct();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function toResponse($request)
+    {
+        $redirect = '/login';
+
+        if ($this->loginService->awaitingEmailConfirmation($this->user)) {
+            return $this->awaitingEmailConfirmationResponse($request);
+        }
+
+        if ($this->loginService->needsMfaVerification($this->user)) {
+            $redirect = '/mfa/verify';
+        }
+
+        return redirect($redirect);
+    }
+
+    /**
+     * Provide an error response for when the current user's email is not confirmed
+     * in a system which requires it.
+     */
+    protected function awaitingEmailConfirmationResponse(Request $request)
+    {
+        if ($request->wantsJson()) {
+            return response()->json([
+                'error' => [
+                    'code'    => 401,
+                    'message' => trans('errors.email_confirmation_awaiting'),
+                ],
+            ], 401);
+        }
+
+        if (session()->pull('sent-email-confirmation') === true) {
+            return redirect('/register/confirm');
+        }
+
+        return redirect('/register/confirm/awaiting');
+    }
+}
index a13ba3a55d807033b7b79a19d8684396a4649d7c..5c73ca02c6843f99aba269e12c06ffbcd45ceb57 100644 (file)
@@ -6,7 +6,6 @@ use Exception;
 
 class UnauthorizedException extends Exception
 {
-
     /**
      * ApiAuthException constructor.
      */
index 953abb96db2caac51abfa8298619033f28eccb78..e7ddb81c26e84c46da533404a23acc4a978353fd 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class UserRegistrationException extends NotifyException
 {
-
 }
index e19707457a33e47bfee00c944b667bdd9af60a5c..635337c3d072c14eb50344af154f30befc7dc71b 100644 (file)
@@ -1,14 +1,16 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class UserTokenExpiredException extends \Exception
 {
-
     public $userId;
 
     /**
      * UserTokenExpiredException constructor.
+     *
      * @param string $message
-     * @param int $userId
+     * @param int    $userId
      */
     public function __construct(string $message, int $userId)
     {
index 3ed53f72ab42e706db04e51cc9a23b48f45e11c6..db3df8a149f496cae319c8a464a82ca8a0f116bb 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class UserTokenNotFoundException extends \Exception
 {
-
 }
index 81e95b16fd201a3ae7ccd1d58ea8dac242823323..af2c3e201b85097aae14b8ab94c337f81767f591 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class UserUpdateException extends NotifyException
 {
diff --git a/app/Exceptions/WhoopsBookStackPrettyHandler.php b/app/Exceptions/WhoopsBookStackPrettyHandler.php
new file mode 100644 (file)
index 0000000..dcf50fa
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace BookStack\Exceptions;
+
+use Whoops\Handler\Handler;
+
+class WhoopsBookStackPrettyHandler extends Handler
+{
+    /**
+     * @return int|null A handler may return nothing, or a Handler::HANDLE_* constant
+     */
+    public function handle()
+    {
+        $exception = $this->getException();
+
+        echo view('errors.debug', [
+            'error'       => $exception->getMessage(),
+            'errorClass'  => get_class($exception),
+            'trace'       => $exception->getTraceAsString(),
+            'environment' => $this->getEnvironment(),
+        ])->render();
+
+        return Handler::QUIT;
+    }
+
+    protected function safeReturn(callable $callback, $default = null)
+    {
+        try {
+            return $callback();
+        } catch (\Exception $e) {
+            return $default;
+        }
+    }
+
+    protected function getEnvironment(): array
+    {
+        return [
+            'PHP Version'       => phpversion(),
+            'BookStack Version' => $this->safeReturn(function () {
+                $versionFile = base_path('version');
+
+                return trim(file_get_contents($versionFile));
+            }, 'unknown'),
+            'Theme Configured' => $this->safeReturn(function () {
+                return config('view.theme');
+            }) ?? 'None',
+        ];
+    }
+}
index 30e4b785fdb0bdb6712fcc8b448070bd23ba7345..6c279a057f17fdd05e58a49d04aaa2b897c05500 100644 (file)
@@ -1,7 +1,12 @@
-<?php namespace BookStack\Facades;
+<?php
+
+namespace BookStack\Facades;
 
 use Illuminate\Support\Facades\Facade;
 
+/**
+ * @see \BookStack\Actions\ActivityLogger
+ */
 class Activity extends Facade
 {
     /**
diff --git a/app/Facades/Images.php b/app/Facades/Images.php
deleted file mode 100644 (file)
index fdbd35a..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<?php namespace BookStack\Facades;
-
-use Illuminate\Support\Facades\Facade;
-
-class Images extends Facade
-{
-    /**
-     * Get the registered name of the component.
-     *
-     * @return string
-     */
-    protected static function getFacadeAccessor()
-    {
-        return 'images';
-    }
-}
index c552d7cdb03d59ca512abe9419c0b554f5596be6..74cbe46fef330eec9874157d3b5d58f10fafb609 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Facades;
+<?php
+
+namespace BookStack\Facades;
 
 use Illuminate\Support\Facades\Facade;
 
index 9f4f61ecb47cfeb3938a6e399d3906e9bc7cdce2..79867ae6d6aca53a7cd2698e0a42b61e06ccaa42 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Facades;
+<?php
+
+namespace BookStack\Facades;
 
 use BookStack\Theming\ThemeService;
 use Illuminate\Support\Facades\Facade;
diff --git a/app/Facades/Views.php b/app/Facades/Views.php
deleted file mode 100644 (file)
index f535711..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<?php namespace BookStack\Facades;
-
-use Illuminate\Support\Facades\Facade;
-
-class Views extends Facade
-{
-    /**
-     * Get the registered name of the component.
-     *
-     * @return string
-     */
-    protected static function getFacadeAccessor()
-    {
-        return 'views';
-    }
-}
index 5eb8b1e3d286f0f82ce23b41362b1dbd557f7507..5d6f4a926c9ce4c5a186106f986f52361ef46583 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Api\ListingResponseBuilder;
 use BookStack\Http\Controllers\Controller;
@@ -7,7 +9,6 @@ use Illuminate\Http\JsonResponse;
 
 abstract class ApiController extends Controller
 {
-
     protected $rules = [];
     protected $printHidden = [];
 
@@ -18,14 +19,20 @@ abstract class ApiController extends Controller
     protected function apiListingResponse(Builder $query, array $fields, array $protectedFieldsToPrint = []): JsonResponse
     {
         $listing = new ListingResponseBuilder($query, request(), $fields, $protectedFieldsToPrint);
+
         return $listing->toResponse();
     }
 
     /**
      * Get the validation rules for this controller.
+     * Defaults to a $rules property but can be a rules() method.
      */
     public function getValdationRules(): array
     {
+        if (method_exists($this, 'rules')) {
+            return $this->rules();
+        }
+
         return $this->rules;
     }
 }
index c63ca698cc53808edad9c279f7066af417fcad39..a1453e7684bb0c4bb7e55534bf1eb2f8e268effe 100644 (file)
@@ -1,10 +1,11 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Api\ApiDocsGenerator;
 
 class ApiDocsController extends ApiController
 {
-
     /**
      * Load the docs page for the API.
      */
@@ -12,6 +13,7 @@ class ApiDocsController extends ApiController
     {
         $docs = ApiDocsGenerator::generateConsideringCache();
         $this->setPageTitle(trans('settings.users_api_tokens_docs'));
+
         return view('api-docs.index', [
             'docs' => $docs,
         ]);
@@ -23,6 +25,7 @@ class ApiDocsController extends ApiController
     public function json()
     {
         $docs = ApiDocsGenerator::generateConsideringCache();
+
         return response()->json($docs);
     }
 }
diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php
new file mode 100644 (file)
index 0000000..fc5008e
--- /dev/null
@@ -0,0 +1,168 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\FileUploadException;
+use BookStack\Uploads\Attachment;
+use BookStack\Uploads\AttachmentService;
+use Exception;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+class AttachmentApiController extends ApiController
+{
+    protected $attachmentService;
+
+    public function __construct(AttachmentService $attachmentService)
+    {
+        $this->attachmentService = $attachmentService;
+    }
+
+    /**
+     * Get a listing of attachments visible to the user.
+     * The external property indicates whether the attachment is simple a link.
+     * A false value for the external property would indicate a file upload.
+     */
+    public function list()
+    {
+        return $this->apiListingResponse(Attachment::visible(), [
+            'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by',
+        ]);
+    }
+
+    /**
+     * Create a new attachment in the system.
+     * An uploaded_to value must be provided containing an ID of the page
+     * that this upload will be related to.
+     *
+     * If you're uploading a file the POST data should be provided via
+     * a multipart/form-data type request instead of JSON.
+     *
+     * @throws ValidationException
+     * @throws FileUploadException
+     */
+    public function create(Request $request)
+    {
+        $this->checkPermission('attachment-create-all');
+        $requestData = $this->validate($request, $this->rules()['create']);
+
+        $pageId = $request->get('uploaded_to');
+        $page = Page::visible()->findOrFail($pageId);
+        $this->checkOwnablePermission('page-update', $page);
+
+        if ($request->hasFile('file')) {
+            $uploadedFile = $request->file('file');
+            $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id);
+        } else {
+            $attachment = $this->attachmentService->saveNewFromLink(
+                $requestData['name'],
+                $requestData['link'],
+                $page->id
+            );
+        }
+
+        $this->attachmentService->updateFile($attachment, $requestData);
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Get the details & content of a single attachment of the given ID.
+     * The attachment link or file content is provided via a 'content' property.
+     * For files the content will be base64 encoded.
+     *
+     * @throws FileNotFoundException
+     */
+    public function read(string $id)
+    {
+        /** @var Attachment $attachment */
+        $attachment = Attachment::visible()
+            ->with(['createdBy', 'updatedBy'])
+            ->findOrFail($id);
+
+        $attachment->setAttribute('links', [
+            'html'     => $attachment->htmlLink(),
+            'markdown' => $attachment->markdownLink(),
+        ]);
+
+        if (!$attachment->external) {
+            $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
+            $attachment->setAttribute('content', base64_encode($attachmentContents));
+        } else {
+            $attachment->setAttribute('content', $attachment->path);
+        }
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Update the details of a single attachment.
+     * As per the create endpoint, if a file is being provided as the attachment content
+     * the request should be formatted as a multipart/form-data request instead of JSON.
+     *
+     * @throws ValidationException
+     * @throws FileUploadException
+     */
+    public function update(Request $request, string $id)
+    {
+        $requestData = $this->validate($request, $this->rules()['update']);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::visible()->findOrFail($id);
+
+        $page = $attachment->page;
+        if ($requestData['uploaded_to'] ?? false) {
+            $pageId = $request->get('uploaded_to');
+            $page = Page::visible()->findOrFail($pageId);
+            $attachment->uploaded_to = $requestData['uploaded_to'];
+        }
+
+        $this->checkOwnablePermission('page-view', $page);
+        $this->checkOwnablePermission('page-update', $page);
+        $this->checkOwnablePermission('attachment-update', $attachment);
+
+        if ($request->hasFile('file')) {
+            $uploadedFile = $request->file('file');
+            $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
+        }
+
+        $this->attachmentService->updateFile($attachment, $requestData);
+
+        return response()->json($attachment);
+    }
+
+    /**
+     * Delete an attachment of the given ID.
+     *
+     * @throws Exception
+     */
+    public function delete(string $id)
+    {
+        /** @var Attachment $attachment */
+        $attachment = Attachment::visible()->findOrFail($id);
+        $this->checkOwnablePermission('attachment-delete', $attachment);
+
+        $this->attachmentService->deleteFile($attachment);
+
+        return response('', 204);
+    }
+
+    protected function rules(): array
+    {
+        return [
+            'create' => [
+                'name'        => ['required', 'min:1', 'max:255', 'string'],
+                'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+                'file'        => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
+                'link'        => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
+            ],
+            'update' => [
+                'name'        => ['min:1', 'max:255', 'string'],
+                'uploaded_to' => ['integer', 'exists:pages,id'],
+                'file'        => $this->attachmentService->getFileValidationRules(),
+                'link'        => ['min:1', 'max:255', 'safe_url'],
+            ],
+        ];
+    }
+}
index 81ac9c7aa6c56e44bd2551e379d37e1f75ec5882..b28e3eefa3af93802c894bfd8436d41176bf0702 100644 (file)
@@ -1,27 +1,26 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BookRepo;
-use BookStack\Exceptions\NotifyException;
-use Illuminate\Contracts\Container\BindingResolutionException;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
 class BookApiController extends ApiController
 {
-
     protected $bookRepo;
 
     protected $rules = [
         'create' => [
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'tags' => 'array',
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'tags'        => ['array'],
         ],
         'update' => [
-            'name' => 'string|min:1|max:255',
-            'description' => 'string|max:1000',
-            'tags' => 'array',
+            'name'        => ['string', 'min:1', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'tags'        => ['array'],
         ],
     ];
 
@@ -36,6 +35,7 @@ class BookApiController extends ApiController
     public function list()
     {
         $books = Book::visible();
+
         return $this->apiListingResponse($books, [
             'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
         ]);
@@ -43,6 +43,7 @@ class BookApiController extends ApiController
 
     /**
      * Create a new book in the system.
+     *
      * @throws ValidationException
      */
     public function create(Request $request)
@@ -51,6 +52,7 @@ class BookApiController extends ApiController
         $requestData = $this->validate($request, $this->rules['create']);
 
         $book = $this->bookRepo->create($requestData);
+
         return response()->json($book);
     }
 
@@ -60,11 +62,13 @@ class BookApiController extends ApiController
     public function read(string $id)
     {
         $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
+
         return response()->json($book);
     }
 
     /**
      * Update the details of a single book.
+     *
      * @throws ValidationException
      */
     public function update(Request $request, string $id)
@@ -81,6 +85,7 @@ class BookApiController extends ApiController
     /**
      * Delete a single book.
      * This will typically send the book to the recycle bin.
+     *
      * @throws \Exception
      */
     public function delete(string $id)
@@ -89,6 +94,7 @@ class BookApiController extends ApiController
         $this->checkOwnablePermission('book-delete', $book);
 
         $this->bookRepo->destroy($book);
+
         return response('', 204);
     }
 }
index 3d813c4d4225fabdaa686d9b15881871f85303ad..028bc3a817ebf726b358ca0f7b8d47f393695bf8 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Tools\ExportFormatter;
@@ -11,27 +13,32 @@ class BookExportApiController extends ApiController
     public function __construct(ExportFormatter $exportFormatter)
     {
         $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Export a book as a PDF file.
+     *
      * @throws Throwable
      */
     public function exportPdf(int $id)
     {
         $book = Book::visible()->findOrFail($id);
         $pdfContent = $this->exportFormatter->bookToPdf($book);
+
         return $this->downloadResponse($pdfContent, $book->slug . '.pdf');
     }
 
     /**
      * Export a book as a contained HTML file.
+     *
      * @throws Throwable
      */
     public function exportHtml(int $id)
     {
         $book = Book::visible()->findOrFail($id);
         $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
+
         return $this->downloadResponse($htmlContent, $book->slug . '.html');
     }
 
@@ -42,6 +49,18 @@ class BookExportApiController extends ApiController
     {
         $book = Book::visible()->findOrFail($id);
         $textContent = $this->exportFormatter->bookToPlainText($book);
+
         return $this->downloadResponse($textContent, $book->slug . '.txt');
     }
+
+    /**
+     * Export a book as a markdown file.
+     */
+    public function exportMarkdown(int $id)
+    {
+        $book = Book::visible()->findOrFail($id);
+        $markdown = $this->exportFormatter->bookToMarkdown($book);
+
+        return $this->downloadResponse($markdown, $book->slug . '.md');
+    }
 }
index 4ce93defa9e152d7b7837cae05618277fccab0ba..bd4f23a1093257cecd971a030d5c48109fc3a313 100644 (file)
@@ -1,7 +1,9 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Repos\BookshelfRepo;
 use Exception;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Http\Request;
@@ -9,7 +11,6 @@ use Illuminate\Validation\ValidationException;
 
 class BookshelfApiController extends ApiController
 {
-
     /**
      * @var BookshelfRepo
      */
@@ -17,14 +18,14 @@ class BookshelfApiController extends ApiController
 
     protected $rules = [
         'create' => [
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'books' => 'array',
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'books'       => ['array'],
         ],
         'update' => [
-            'name' => 'string|min:1|max:255',
-            'description' => 'string|max:1000',
-            'books' => 'array',
+            'name'        => ['string', 'min:1', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'books'       => ['array'],
         ],
     ];
 
@@ -42,6 +43,7 @@ class BookshelfApiController extends ApiController
     public function list()
     {
         $shelves = Bookshelf::visible();
+
         return $this->apiListingResponse($shelves, [
             'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
         ]);
@@ -51,6 +53,7 @@ class BookshelfApiController extends ApiController
      * Create a new shelf in the system.
      * An array of books IDs can be provided in the request. These
      * will be added to the shelf in the same order as provided.
+     *
      * @throws ValidationException
      */
     public function create(Request $request)
@@ -72,9 +75,10 @@ class BookshelfApiController extends ApiController
         $shelf = Bookshelf::visible()->with([
             'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
             'books' => function (BelongsToMany $query) {
-                $query->visible()->get(['id', 'name', 'slug']);
-            }
+                $query->scopes('visible')->get(['id', 'name', 'slug']);
+            },
         ])->findOrFail($id);
+
         return response()->json($shelf);
     }
 
@@ -83,6 +87,7 @@ class BookshelfApiController extends ApiController
      * An array of books IDs can be provided in the request. These
      * will be added to the shelf in the same order as provided and overwrite
      * any existing book assignments.
+     *
      * @throws ValidationException
      */
     public function update(Request $request, string $id)
@@ -94,14 +99,14 @@ class BookshelfApiController extends ApiController
         $bookIds = $request->get('books', null);
 
         $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
+
         return response()->json($shelf);
     }
 
-
-
     /**
      * Delete a single shelf.
      * This will typically send the shelf to the recycle bin.
+     *
      * @throws Exception
      */
     public function delete(string $id)
@@ -110,6 +115,7 @@ class BookshelfApiController extends ApiController
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
         $this->bookshelfRepo->destroy($shelf);
+
         return response('', 204);
     }
 }
index e58c1c8e147dd6b8549ef10135fb9c1fa7c17730..8459b84499ee362919e87833ad7527b2a7e33928 100644 (file)
@@ -1,10 +1,10 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
-use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Repos\ChapterRepo;
-use BookStack\Facades\Activity;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Http\Request;
 
@@ -14,16 +14,16 @@ class ChapterApiController extends ApiController
 
     protected $rules = [
         'create' => [
-            'book_id' => 'required|integer',
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'tags' => 'array',
+            'book_id'     => ['required', 'integer'],
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'tags'        => ['array'],
         ],
         'update' => [
-            'book_id' => 'integer',
-            'name' => 'string|min:1|max:255',
-            'description' => 'string|max:1000',
-            'tags' => 'array',
+            'book_id'     => ['integer'],
+            'name'        => ['string', 'min:1', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'tags'        => ['array'],
         ],
     ];
 
@@ -41,6 +41,7 @@ class ChapterApiController extends ApiController
     public function list()
     {
         $chapters = Chapter::visible();
+
         return $this->apiListingResponse($chapters, [
             'id', 'book_id', 'name', 'slug', 'description', 'priority',
             'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
@@ -59,6 +60,7 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-create', $book);
 
         $chapter = $this->chapterRepo->create($request->all(), $book);
+
         return response()->json($chapter->load(['tags']));
     }
 
@@ -68,8 +70,9 @@ class ChapterApiController extends ApiController
     public function read(string $id)
     {
         $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
-            $query->visible()->get(['id', 'name', 'slug']);
+            $query->scopes('visible')->get(['id', 'name', 'slug']);
         }])->findOrFail($id);
+
         return response()->json($chapter);
     }
 
@@ -82,6 +85,7 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-update', $chapter);
 
         $updatedChapter = $this->chapterRepo->update($chapter, $request->all());
+
         return response()->json($updatedChapter->load(['tags']));
     }
 
@@ -95,6 +99,7 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
         $this->chapterRepo->destroy($chapter);
+
         return response('', 204);
     }
 }
index afdfe555dd56f1bee4bc0a40139476b8a2d80c4f..5715ab2e37c6c9f7e682ad69b407f27153e3934e 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Tools\ExportFormatter;
-use BookStack\Entities\Repos\BookRepo;
 use Throwable;
 
 class ChapterExportApiController extends ApiController
@@ -15,27 +16,32 @@ class ChapterExportApiController extends ApiController
     public function __construct(ExportFormatter $exportFormatter)
     {
         $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Export a chapter as a PDF file.
+     *
      * @throws Throwable
      */
     public function exportPdf(int $id)
     {
         $chapter = Chapter::visible()->findOrFail($id);
         $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
+
         return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
     }
 
     /**
      * Export a chapter as a contained HTML file.
+     *
      * @throws Throwable
      */
     public function exportHtml(int $id)
     {
         $chapter = Chapter::visible()->findOrFail($id);
         $htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
+
         return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
     }
 
@@ -46,6 +52,18 @@ class ChapterExportApiController extends ApiController
     {
         $chapter = Chapter::visible()->findOrFail($id);
         $textContent = $this->exportFormatter->chapterToPlainText($chapter);
+
         return $this->downloadResponse($textContent, $chapter->slug . '.txt');
     }
+
+    /**
+     * Export a chapter as a markdown file.
+     */
+    public function exportMarkdown(int $id)
+    {
+        $chapter = Chapter::visible()->findOrFail($id);
+        $markdown = $this->exportFormatter->chapterToMarkdown($chapter);
+
+        return $this->downloadResponse($markdown, $chapter->slug . '.md');
+    }
 }
index a6db0583380e610626cef1794e8e5d8a2e501ea1..6f3a71e029ba9b4bab542cb29c32a100dc5e6fac 100644 (file)
@@ -16,20 +16,20 @@ class PageApiController extends ApiController
 
     protected $rules = [
         'create' => [
-            'book_id' => 'required_without:chapter_id|integer',
-            'chapter_id' => 'required_without:book_id|integer',
-            'name' => 'required|string|max:255',
-            'html' => 'required_without:markdown|string',
-            'markdown' => 'required_without:html|string',
-            'tags' => 'array',
+            'book_id'    => ['required_without:chapter_id', 'integer'],
+            'chapter_id' => ['required_without:book_id', 'integer'],
+            'name'       => ['required', 'string', 'max:255'],
+            'html'       => ['required_without:markdown', 'string'],
+            'markdown'   => ['required_without:html', 'string'],
+            'tags'       => ['array'],
         ],
         'update' => [
-            'book_id' => 'required|integer',
-            'chapter_id' => 'required|integer',
-            'name' => 'string|min:1|max:255',
-            'html' => 'string',
-            'markdown' => 'string',
-            'tags' => 'array',
+            'book_id'    => ['required', 'integer'],
+            'chapter_id' => ['required', 'integer'],
+            'name'       => ['string', 'min:1', 'max:255'],
+            'html'       => ['string'],
+            'markdown'   => ['string'],
+            'tags'       => ['array'],
         ],
     ];
 
@@ -44,6 +44,7 @@ class PageApiController extends ApiController
     public function list()
     {
         $pages = Page::visible();
+
         return $this->apiListingResponse($pages, [
             'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
             'draft', 'template',
@@ -60,6 +61,8 @@ class PageApiController extends ApiController
      *
      * Any HTML content provided should be kept to a single-block depth of plain HTML
      * elements to remain compatible with the BookStack front-end and editors.
+     * Any images included via base64 data URIs will be extracted and saved as gallery
+     * images against the page during upload.
      */
     public function create(Request $request)
     {
@@ -87,6 +90,7 @@ class PageApiController extends ApiController
     public function read(string $id)
     {
         $page = $this->pageRepo->getById($id, []);
+
         return response()->json($page->forJsonDisplay());
     }
 
@@ -105,12 +109,13 @@ class PageApiController extends ApiController
         $parent = null;
         if ($request->has('chapter_id')) {
             $parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
-        } else if ($request->has('book_id')) {
+        } elseif ($request->has('book_id')) {
             $parent = Book::visible()->findOrFail($request->get('book_id'));
         }
 
         if ($parent && !$parent->matches($page->getParent())) {
             $this->checkOwnablePermission('page-delete', $page);
+
             try {
                 $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
             } catch (Exception $exception) {
@@ -123,6 +128,7 @@ class PageApiController extends ApiController
         }
 
         $updatedPage = $this->pageRepo->update($page, $request->all());
+
         return response()->json($updatedPage->forJsonDisplay());
     }
 
@@ -136,6 +142,7 @@ class PageApiController extends ApiController
         $this->checkOwnablePermission('page-delete', $page);
 
         $this->pageRepo->destroy($page);
+
         return response('', 204);
     }
 }
index 7563092cb36671c81ce826fae220b55fb5247c15..ce5700c79b9add01a9b2eb8d418f5b3a785344e8 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Tools\ExportFormatter;
@@ -11,27 +13,32 @@ class PageExportApiController extends ApiController
     public function __construct(ExportFormatter $exportFormatter)
     {
         $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Export a page as a PDF file.
+     *
      * @throws Throwable
      */
     public function exportPdf(int $id)
     {
         $page = Page::visible()->findOrFail($id);
         $pdfContent = $this->exportFormatter->pageToPdf($page);
+
         return $this->downloadResponse($pdfContent, $page->slug . '.pdf');
     }
 
     /**
      * Export a page as a contained HTML file.
+     *
      * @throws Throwable
      */
     public function exportHtml(int $id)
     {
         $page = Page::visible()->findOrFail($id);
         $htmlContent = $this->exportFormatter->pageToContainedHtml($page);
+
         return $this->downloadResponse($htmlContent, $page->slug . '.html');
     }
 
@@ -42,6 +49,18 @@ class PageExportApiController extends ApiController
     {
         $page = Page::visible()->findOrFail($id);
         $textContent = $this->exportFormatter->pageToPlainText($page);
+
         return $this->downloadResponse($textContent, $page->slug . '.txt');
     }
+
+    /**
+     * Export a page as a markdown file.
+     */
+    public function exportMarkdown(int $id)
+    {
+        $page = Page::visible()->findOrFail($id);
+        $markdown = $this->exportFormatter->pageToMarkdown($page);
+
+        return $this->downloadResponse($markdown, $page->slug . '.md');
+    }
 }
diff --git a/app/Http/Controllers/Api/SearchApiController.php b/app/Http/Controllers/Api/SearchApiController.php
new file mode 100644 (file)
index 0000000..5c4112f
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\SearchOptions;
+use BookStack\Entities\Tools\SearchResultsFormatter;
+use BookStack\Entities\Tools\SearchRunner;
+use Illuminate\Http\Request;
+
+class SearchApiController extends ApiController
+{
+    protected $searchRunner;
+    protected $resultsFormatter;
+
+    protected $rules = [
+        'all' => [
+            'query'  => ['required'],
+            'page'   => ['integer', 'min:1'],
+            'count'  => ['integer', 'min:1', 'max:100'],
+        ],
+    ];
+
+    public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
+    {
+        $this->searchRunner = $searchRunner;
+        $this->resultsFormatter = $resultsFormatter;
+    }
+
+    /**
+     * Run a search query against all main content types (shelves, books, chapters & pages)
+     * in the system. Takes the same input as the main search bar within the BookStack
+     * interface as a 'query' parameter. See https://www.bookstackapp.com/docs/user/searching/
+     * for a full list of search term options. Results contain a 'type' property to distinguish
+     * between: bookshelf, book, chapter & page.
+     *
+     * The paging parameters and response format emulates a standard listing endpoint
+     * but standard sorting and filtering cannot be done on this endpoint. If a count value
+     * is provided this will only be taken as a suggestion. The results in the response
+     * may currently be up to 4x this value.
+     */
+    public function all(Request $request)
+    {
+        $this->validate($request, $this->rules['all']);
+
+        $options = SearchOptions::fromString($request->get('query') ?? '');
+        $page = intval($request->get('page', '0')) ?: 1;
+        $count = min(intval($request->get('count', '0')) ?: 20, 100);
+
+        $results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
+        $this->resultsFormatter->format($results['results']->all(), $options);
+
+        /** @var Entity $result */
+        foreach ($results['results'] as $result) {
+            $result->setVisible([
+                'id', 'name', 'slug', 'book_id',
+                'chapter_id', 'draft', 'template',
+                'created_at', 'updated_at',
+                'tags', 'type', 'preview_html', 'url',
+            ]);
+            $result->setAttribute('type', $result->getType());
+            $result->setAttribute('url', $result->getUrl());
+            $result->setAttribute('preview_html', [
+                'name'    => (string) $result->getAttribute('preview_name'),
+                'content' => (string) $result->getAttribute('preview_content'),
+            ]);
+        }
+
+        return response()->json([
+            'data'  => $results['results'],
+            'total' => $results['total'],
+        ]);
+    }
+}
index 04e89ac5d1a0db18407398aabd3689951822eee8..084f6f96ad8866469346c4d0469636820f799a9c 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\FileUploadException;
@@ -14,30 +16,28 @@ use Illuminate\Validation\ValidationException;
 class AttachmentController extends Controller
 {
     protected $attachmentService;
-    protected $attachment;
     protected $pageRepo;
 
     /**
      * AttachmentController constructor.
      */
-    public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
+    public function __construct(AttachmentService $attachmentService, PageRepo $pageRepo)
     {
         $this->attachmentService = $attachmentService;
-        $this->attachment = $attachment;
         $this->pageRepo = $pageRepo;
     }
 
-
     /**
      * Endpoint at which attachments are uploaded to.
+     *
      * @throws ValidationException
      * @throws NotFoundException
      */
     public function upload(Request $request)
     {
         $this->validate($request, [
-            'uploaded_to' => 'required|integer|exists:pages,id',
-            'file' => 'required|file'
+            'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+            'file'        => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
         ]);
 
         $pageId = $request->get('uploaded_to');
@@ -59,15 +59,17 @@ class AttachmentController extends Controller
 
     /**
      * Update an uploaded attachment.
+     *
      * @throws ValidationException
      */
     public function uploadUpdate(Request $request, $attachmentId)
     {
         $this->validate($request, [
-            'file' => 'required|file'
+            'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
         ]);
 
-        $attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
         $this->checkOwnablePermission('view', $attachment->page);
         $this->checkOwnablePermission('page-update', $attachment->page);
         $this->checkOwnablePermission('attachment-create', $attachment);
@@ -85,11 +87,11 @@ class AttachmentController extends Controller
 
     /**
      * Get the update form for an attachment.
-     * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
      */
     public function getUpdateForm(string $attachmentId)
     {
-        $attachment = $this->attachment->findOrFail($attachmentId);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
 
         $this->checkOwnablePermission('page-update', $attachment->page);
         $this->checkOwnablePermission('attachment-create', $attachment);
@@ -104,23 +106,24 @@ class AttachmentController extends Controller
      */
     public function update(Request $request, string $attachmentId)
     {
-        $attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
 
         try {
             $this->validate($request, [
-                'attachment_edit_name' => 'required|string|min:1|max:255',
-                'attachment_edit_url' =>  'string|min:1|max:255|safe_url'
+                'attachment_edit_name' => ['required', 'string', 'min:1', 'max:255'],
+                'attachment_edit_url'  => ['string', 'min:1', 'max:255', 'safe_url'],
             ]);
         } catch (ValidationException $exception) {
             return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
                 'attachment' => $attachment,
-                'errors' => new MessageBag($exception->errors()),
+                'errors'     => new MessageBag($exception->errors()),
             ]), 422);
         }
 
-        $this->checkOwnablePermission('view', $attachment->page);
+        $this->checkOwnablePermission('page-view', $attachment->page);
         $this->checkOwnablePermission('page-update', $attachment->page);
-        $this->checkOwnablePermission('attachment-create', $attachment);
+        $this->checkOwnablePermission('attachment-update', $attachment);
 
         $attachment = $this->attachmentService->updateFile($attachment, [
             'name' => $request->get('attachment_edit_name'),
@@ -134,6 +137,7 @@ class AttachmentController extends Controller
 
     /**
      * Attach a link to a page.
+     *
      * @throws NotFoundException
      */
     public function attachLink(Request $request)
@@ -142,9 +146,9 @@ class AttachmentController extends Controller
 
         try {
             $this->validate($request, [
-                'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
-                'attachment_link_name' => 'required|string|min:1|max:255',
-                'attachment_link_url' =>  'required|string|min:1|max:255|safe_url'
+                'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
+                'attachment_link_name'        => ['required', 'string', 'min:1', 'max:255'],
+                'attachment_link_url'         => ['required', 'string', 'min:1', 'max:255', 'safe_url'],
             ]);
         } catch (ValidationException $exception) {
             return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
@@ -160,7 +164,7 @@ class AttachmentController extends Controller
 
         $attachmentName = $request->get('attachment_link_name');
         $link = $request->get('attachment_link_url');
-        $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
+        $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
 
         return view('attachments.manager-link-form', [
             'pageId' => $pageId,
@@ -169,11 +173,14 @@ class AttachmentController extends Controller
 
     /**
      * Get the attachments for a specific page.
+     *
+     * @throws NotFoundException
      */
     public function listForPage(int $pageId)
     {
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-view', $page);
+
         return view('attachments.manager-list', [
             'attachments' => $page->attachments->all(),
         ]);
@@ -181,30 +188,35 @@ class AttachmentController extends Controller
 
     /**
      * Update the attachment sorting.
+     *
      * @throws ValidationException
      * @throws NotFoundException
      */
     public function sortForPage(Request $request, int $pageId)
     {
         $this->validate($request, [
-            'order' => 'required|array',
+            'order' => ['required', 'array'],
         ]);
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
 
         $attachmentOrder = $request->get('order');
         $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
+
         return response()->json(['message' => trans('entities.attachments_order_updated')]);
     }
 
     /**
      * Get an attachment from storage.
+     *
      * @throws FileNotFoundException
      * @throws NotFoundException
      */
-    public function get(string $attachmentId)
+    public function get(Request $request, string $attachmentId)
     {
-        $attachment = $this->attachment->findOrFail($attachmentId);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
+
         try {
             $page = $this->pageRepo->getById($attachment->uploaded_to);
         } catch (NotFoundException $exception) {
@@ -217,19 +229,28 @@ class AttachmentController extends Controller
             return redirect($attachment->path);
         }
 
+        $fileName = $attachment->getFileName();
         $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
-        return $this->downloadResponse($attachmentContents, $attachment->getFileName());
+
+        if ($request->get('open') === 'true') {
+            return $this->inlineDownloadResponse($attachmentContents, $fileName);
+        }
+
+        return $this->downloadResponse($attachmentContents, $fileName);
     }
 
     /**
      * Delete a specific attachment in the system.
+     *
      * @throws Exception
      */
     public function delete(string $attachmentId)
     {
-        $attachment = $this->attachment->findOrFail($attachmentId);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
         $this->checkOwnablePermission('attachment-delete', $attachment);
         $this->attachmentService->deleteFile($attachment);
+
         return response()->json(['message' => trans('entities.attachments_deleted')]);
     }
 }
index f73ee4a20d227e0907ba51b7f07e8273ee580e04..ec3f3697534fcabe6e74ddf4759e610160bcca75 100644 (file)
@@ -8,19 +8,19 @@ use Illuminate\Support\Facades\DB;
 
 class AuditLogController extends Controller
 {
-
     public function index(Request $request)
     {
         $this->checkPermission('settings-manage');
         $this->checkPermission('users-manage');
 
         $listDetails = [
-            'order' => $request->get('order', 'desc'),
-            'event' => $request->get('event', ''),
-            'sort' => $request->get('sort', 'created_at'),
+            'order'     => $request->get('order', 'desc'),
+            'event'     => $request->get('event', ''),
+            'sort'      => $request->get('sort', 'created_at'),
             'date_from' => $request->get('date_from', ''),
-            'date_to' => $request->get('date_to', ''),
-            'user' => $request->get('user', ''),
+            'date_to'   => $request->get('date_to', ''),
+            'user'      => $request->get('user', ''),
+            'ip'        => $request->get('ip', ''),
         ];
 
         $query = Activity::query()
@@ -28,7 +28,7 @@ class AuditLogController extends Controller
                 'entity' => function ($query) {
                     $query->withTrashed();
                 },
-                'user'
+                'user',
             ])
             ->orderBy($listDetails['sort'], $listDetails['order']);
 
@@ -45,15 +45,19 @@ class AuditLogController extends Controller
         if ($listDetails['date_to']) {
             $query->where('created_at', '<=', $listDetails['date_to']);
         }
+        if ($listDetails['ip']) {
+            $query->where('ip', 'like', $listDetails['ip'] . '%');
+        }
 
         $activities = $query->paginate(100);
         $activities->appends($listDetails);
 
         $types = DB::table('activities')->select('type')->distinct()->pluck('type');
         $this->setPageTitle(trans('settings.audit'));
+
         return view('settings.audit', [
-            'activities' => $activities,
-            'listDetails' => $listDetails,
+            'activities'    => $activities,
+            'listDetails'   => $listDetails,
             'activityTypes' => $types,
         ]);
     }
index 6e6a0e779d0871e49e9863ffd76a5f8373288395..873d88475eb2bab2c3bcd8033136aa2ff2cce0bd 100644 (file)
@@ -2,36 +2,35 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
-use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\EmailConfirmationService;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\ConfirmationEmailException;
 use BookStack\Exceptions\UserTokenExpiredException;
 use BookStack\Exceptions\UserTokenNotFoundException;
-use BookStack\Facades\Theme;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
 use Exception;
-use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
-use Illuminate\Routing\Redirector;
-use Illuminate\View\View;
 
 class ConfirmEmailController extends Controller
 {
     protected $emailConfirmationService;
+    protected $loginService;
     protected $userRepo;
 
     /**
      * Create a new controller instance.
      */
-    public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
-    {
+    public function __construct(
+        EmailConfirmationService $emailConfirmationService,
+        LoginService $loginService,
+        UserRepo $userRepo
+    ) {
         $this->emailConfirmationService = $emailConfirmationService;
+        $this->loginService = $loginService;
         $this->userRepo = $userRepo;
     }
 
-
     /**
      * Show the page to tell the user to check their email
      * and confirm their address.
@@ -44,63 +43,53 @@ class ConfirmEmailController extends Controller
     /**
      * Shows a notice that a user's email address has not been confirmed,
      * Also has the option to re-send the confirmation email.
-     * @return View
      */
     public function showAwaiting()
     {
-        return view('auth.user-unconfirmed');
+        $user = $this->loginService->getLastLoginAttemptUser();
+
+        return view('auth.user-unconfirmed', ['user' => $user]);
     }
 
     /**
      * Confirms an email via a token and logs the user into the system.
-     * @param $token
-     * @return RedirectResponse|Redirector
+     *
      * @throws ConfirmationEmailException
      * @throws Exception
      */
-    public function confirm($token)
+    public function confirm(string $token)
     {
         try {
             $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
-        } catch (Exception $exception) {
-            if ($exception instanceof UserTokenNotFoundException) {
-                $this->showErrorNotification(trans('errors.email_confirmation_invalid'));
-                return redirect('/register');
-            }
-
-            if ($exception instanceof UserTokenExpiredException) {
-                $user = $this->userRepo->getById($exception->userId);
-                $this->emailConfirmationService->sendConfirmation($user);
-                $this->showErrorNotification(trans('errors.email_confirmation_expired'));
-                return redirect('/register/confirm');
-            }
-
-            throw $exception;
+        } catch (UserTokenNotFoundException $exception) {
+            $this->showErrorNotification(trans('errors.email_confirmation_invalid'));
+
+            return redirect('/register');
+        } catch (UserTokenExpiredException $exception) {
+            $user = $this->userRepo->getById($exception->userId);
+            $this->emailConfirmationService->sendConfirmation($user);
+            $this->showErrorNotification(trans('errors.email_confirmation_expired'));
+
+            return redirect('/register/confirm');
         }
 
         $user = $this->userRepo->getById($userId);
         $user->email_confirmed = true;
         $user->save();
 
-        auth()->login($user);
-        Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
-        $this->logActivity(ActivityType::AUTH_LOGIN, $user);
-        $this->showSuccessNotification(trans('auth.email_confirm_success'));
         $this->emailConfirmationService->deleteByUser($user);
+        $this->showSuccessNotification(trans('auth.email_confirm_success'));
 
-        return redirect('/');
+        return redirect('/login');
     }
 
-
     /**
-     * Resend the confirmation email
-     * @param Request $request
-     * @return View
+     * Resend the confirmation email.
      */
     public function resend(Request $request)
     {
         $this->validate($request, [
-            'email' => 'required|email|exists:users,email'
+            'email' => ['required', 'email', 'exists:users,email'],
         ]);
         $user = $this->userRepo->getByEmail($request->get('email'));
 
@@ -108,10 +97,12 @@ class ConfirmEmailController extends Controller
             $this->emailConfirmationService->sendConfirmation($user);
         } catch (Exception $e) {
             $this->showErrorNotification(trans('auth.email_confirm_send_error'));
+
             return redirect('/register/confirm');
         }
 
         $this->showSuccessNotification(trans('auth.email_confirm_resent'));
+
         return redirect('/register/confirm');
     }
 }
index 5a033c6aad57d89fe7067b1c65016c200e327439..5e73b232ca734f6a4a5126d1b059a067ee314569 100644 (file)
@@ -34,16 +34,18 @@ class ForgotPasswordController extends Controller
         $this->middleware('guard:standard');
     }
 
-
     /**
      * Send a reset link to the given user.
      *
-     * @param  \Illuminate\Http\Request  $request
+     * @param \Illuminate\Http\Request $request
+     *
      * @return \Illuminate\Http\RedirectResponse
      */
     public function sendResetLinkEmail(Request $request)
     {
-        $this->validate($request, ['email' => 'required|email']);
+        $this->validate($request, [
+            'email' => ['required', 'email'],
+        ]);
 
         // We will send the password reset link to this user. Once we have attempted
         // to send the link, we will examine the response then see the message we
@@ -56,9 +58,10 @@ class ForgotPasswordController extends Controller
             $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
         }
 
-        if ($response === Password::RESET_LINK_SENT || $response === Password::INVALID_USER) {
+        if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
             $message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
             $this->showSuccessNotification($message);
+
             return back()->with('status', trans($response));
         }
 
diff --git a/app/Http/Controllers/Auth/HandlesPartialLogins.php b/app/Http/Controllers/Auth/HandlesPartialLogins.php
new file mode 100644 (file)
index 0000000..c7f3621
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\User;
+use BookStack\Exceptions\NotFoundException;
+
+trait HandlesPartialLogins
+{
+    /**
+     * @throws NotFoundException
+     */
+    protected function currentOrLastAttemptedUser(): User
+    {
+        $loginService = app()->make(LoginService::class);
+        $user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
+
+        if (!$user) {
+            throw new NotFoundException('A user for this action could not be found');
+        }
+
+        return $user;
+    }
+}
index 01255f466886005e26c155b7217133bbc6f76825..742e1047284403518e8b3ac73cb081c560be945b 100644 (file)
@@ -2,16 +2,15 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
-use Activity;
-use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Exceptions\LoginAttemptEmailNeededException;
 use BookStack\Exceptions\LoginAttemptException;
-use BookStack\Facades\Theme;
+use BookStack\Facades\Activity;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
 use Illuminate\Foundation\Auth\AuthenticatesUsers;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
 class LoginController extends Controller
 {
@@ -29,23 +28,27 @@ class LoginController extends Controller
     use AuthenticatesUsers;
 
     /**
-     * Redirection paths
+     * Redirection paths.
      */
     protected $redirectTo = '/';
     protected $redirectPath = '/';
     protected $redirectAfterLogout = '/login';
 
     protected $socialAuthService;
+    protected $loginService;
 
     /**
      * Create a new controller instance.
      */
-    public function __construct(SocialAuthService $socialAuthService)
+    public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
     {
         $this->middleware('guest', ['only' => ['getLogin', 'login']]);
-        $this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
+        $this->middleware('guard:standard,ldap', ['only' => ['login']]);
+        $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
 
         $this->socialAuthService = $socialAuthService;
+        $this->loginService = $loginService;
+
         $this->redirectPath = url('/');
         $this->redirectAfterLogout = url('/login');
     }
@@ -73,33 +76,28 @@ class LoginController extends Controller
 
         if ($request->has('email')) {
             session()->flashInput([
-                'email' => $request->get('email'),
-                'password' => (config('app.env') === 'demo') ? $request->get('password', '') : ''
+                'email'    => $request->get('email'),
+                'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
             ]);
         }
 
         // Store the previous location for redirect after login
-        $previous = url()->previous('');
-        if ($previous && $previous !== url('/login') && setting('app-public')) {
-            $isPreviousFromInstance = (strpos($previous, url('/')) === 0);
-            if ($isPreviousFromInstance) {
-                redirect()->setIntendedUrl($previous);
-            }
-        }
+        $this->updateIntendedFromPrevious();
 
         return view('auth.login', [
-          'socialDrivers' => $socialDrivers,
-          'authMethod' => $authMethod,
+            'socialDrivers' => $socialDrivers,
+            'authMethod'    => $authMethod,
         ]);
     }
 
     /**
      * Handle a login request to the application.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
+     * @param \Illuminate\Http\Request $request
      *
      * @throws \Illuminate\Validation\ValidationException
+     *
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
      */
     public function login(Request $request)
     {
@@ -114,6 +112,7 @@ class LoginController extends Controller
             $this->fireLockoutEvent($request);
 
             Activity::logFailedLogin($username);
+
             return $this->sendLockoutResponse($request);
         }
 
@@ -123,6 +122,7 @@ class LoginController extends Controller
             }
         } catch (LoginAttemptException $exception) {
             Activity::logFailedLogin($username);
+
             return $this->sendLoginAttemptExceptionResponse($exception, $request);
         }
 
@@ -132,51 +132,60 @@ class LoginController extends Controller
         $this->incrementLoginAttempts($request);
 
         Activity::logFailedLogin($username);
+
         return $this->sendFailedLoginResponse($request);
     }
 
+    /**
+     * Attempt to log the user into the application.
+     *
+     * @param \Illuminate\Http\Request $request
+     *
+     * @return bool
+     */
+    protected function attemptLogin(Request $request)
+    {
+        return $this->loginService->attempt(
+            $this->credentials($request),
+            auth()->getDefaultDriver(),
+            $request->filled('remember')
+        );
+    }
+
     /**
      * The user has been authenticated.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  mixed  $user
+     * @param \Illuminate\Http\Request $request
+     * @param mixed                    $user
+     *
      * @return mixed
      */
     protected function authenticated(Request $request, $user)
     {
-        // Authenticate on all session guards if a likely admin
-        if ($user->can('users-manage') && $user->can('user-roles-manage')) {
-            $guards = ['standard', 'ldap', 'saml2'];
-            foreach ($guards as $guard) {
-                auth($guard)->login($user);
-            }
-        }
-
-        Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
-        $this->logActivity(ActivityType::AUTH_LOGIN, $user);
         return redirect()->intended($this->redirectPath());
     }
 
     /**
      * Validate the user login request.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return void
+     * @param \Illuminate\Http\Request $request
      *
      * @throws \Illuminate\Validation\ValidationException
+     *
+     * @return void
      */
     protected function validateLogin(Request $request)
     {
-        $rules = ['password' => 'required|string'];
+        $rules = ['password' => ['required', 'string']];
         $authMethod = config('auth.method');
 
         if ($authMethod === 'standard') {
-            $rules['email'] = 'required|email';
+            $rules['email'] = ['required', 'email'];
         }
 
         if ($authMethod === 'ldap') {
-            $rules['username'] = 'required|string';
-            $rules['email'] = 'email';
+            $rules['username'] = ['required', 'string'];
+            $rules['email'] = ['email'];
         }
 
         $request->validate($rules);
@@ -198,4 +207,48 @@ class LoginController extends Controller
 
         return redirect('/login');
     }
+
+    /**
+     * Get the failed login response instance.
+     *
+     * @param \Illuminate\Http\Request $request
+     *
+     * @throws \Illuminate\Validation\ValidationException
+     *
+     * @return \Symfony\Component\HttpFoundation\Response
+     */
+    protected function sendFailedLoginResponse(Request $request)
+    {
+        throw ValidationException::withMessages([
+            $this->username() => [trans('auth.failed')],
+        ])->redirectTo('/login');
+    }
+
+    /**
+     * Update the intended URL location from their previous URL.
+     * Ignores if not from the current app instance or if from certain
+     * login or authentication routes.
+     */
+    protected function updateIntendedFromPrevious(): void
+    {
+        // Store the previous location for redirect after login
+        $previous = url()->previous('');
+        $isPreviousFromInstance = (strpos($previous, url('/')) === 0);
+        if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
+            return;
+        }
+
+        $ignorePrefixList = [
+            '/login',
+            '/mfa',
+        ];
+
+        foreach ($ignorePrefixList as $ignorePrefix) {
+            if (strpos($previous, url($ignorePrefix)) === 0) {
+                return;
+            }
+        }
+
+        redirect()->setIntendedUrl($previous);
+    }
 }
diff --git a/app/Http/Controllers/Auth/MfaBackupCodesController.php b/app/Http/Controllers/Auth/MfaBackupCodesController.php
new file mode 100644 (file)
index 0000000..4ceb394
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\Mfa\BackupCodeService;
+use BookStack\Auth\Access\Mfa\MfaSession;
+use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Http\Controllers\Controller;
+use Exception;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+class MfaBackupCodesController extends Controller
+{
+    use HandlesPartialLogins;
+
+    protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-backup-codes';
+
+    /**
+     * Show a view that generates and displays backup codes.
+     */
+    public function generate(BackupCodeService $codeService)
+    {
+        $codes = $codeService->generateNewSet();
+        session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($codes));
+
+        $downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
+
+        $this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
+
+        return view('mfa.backup-codes-generate', [
+            'codes'       => $codes,
+            'downloadUrl' => $downloadUrl,
+        ]);
+    }
+
+    /**
+     * Confirm the setup of backup codes, storing them against the user.
+     *
+     * @throws Exception
+     */
+    public function confirm()
+    {
+        if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) {
+            return response('No generated codes found in the session', 500);
+        }
+
+        $codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY));
+        MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
+
+        $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes');
+
+        if (!auth()->check()) {
+            $this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
+
+            return redirect('/login');
+        }
+
+        return redirect('/mfa/setup');
+    }
+
+    /**
+     * Verify the MFA method submission on check.
+     *
+     * @throws NotFoundException
+     * @throws ValidationException
+     */
+    public function verify(Request $request, BackupCodeService $codeService, MfaSession $mfaSession, LoginService $loginService)
+    {
+        $user = $this->currentOrLastAttemptedUser();
+        $codes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES) ?? '[]';
+
+        $this->validate($request, [
+            'code' => [
+                'required', 'max:12', 'min:8',
+                function ($attribute, $value, $fail) use ($codeService, $codes) {
+                    if (!$codeService->inputCodeExistsInSet($value, $codes)) {
+                        $fail(trans('validation.backup_codes'));
+                    }
+                },
+            ],
+        ]);
+
+        $updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
+
+        $mfaSession->markVerifiedForUser($user);
+        $loginService->reattemptLoginFor($user);
+
+        if ($codeService->countCodesInSet($updatedCodes) < 5) {
+            $this->showWarningNotification(trans('auth.mfa_backup_codes_usage_limit_warning'));
+        }
+
+        return redirect()->intended();
+    }
+}
diff --git a/app/Http/Controllers/Auth/MfaController.php b/app/Http/Controllers/Auth/MfaController.php
new file mode 100644 (file)
index 0000000..6f6beb8
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+
+class MfaController extends Controller
+{
+    use HandlesPartialLogins;
+
+    /**
+     * Show the view to setup MFA for the current user.
+     */
+    public function setup()
+    {
+        $userMethods = $this->currentOrLastAttemptedUser()
+            ->mfaValues()
+            ->get(['id', 'method'])
+            ->groupBy('method');
+
+        $this->setPageTitle(trans('auth.mfa_setup'));
+
+        return view('mfa.setup', [
+            'userMethods' => $userMethods,
+        ]);
+    }
+
+    /**
+     * Remove an MFA method for the current user.
+     *
+     * @throws \Exception
+     */
+    public function remove(string $method)
+    {
+        if (in_array($method, MfaValue::allMethods())) {
+            $value = user()->mfaValues()->where('method', '=', $method)->first();
+            if ($value) {
+                $value->delete();
+                $this->logActivity(ActivityType::MFA_REMOVE_METHOD, $method);
+            }
+        }
+
+        return redirect('/mfa/setup');
+    }
+
+    /**
+     * Show the page to start an MFA verification.
+     */
+    public function verify(Request $request)
+    {
+        $desiredMethod = $request->get('method');
+        $userMethods = $this->currentOrLastAttemptedUser()
+            ->mfaValues()
+            ->get(['id', 'method'])
+            ->groupBy('method');
+
+        // Basic search for the default option for a user.
+        // (Prioritises totp over backup codes)
+        $method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
+        $otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) {
+            return $method !== $userMethod;
+        })->all();
+
+        return view('mfa.verify', [
+            'userMethods'  => $userMethods,
+            'method'       => $method,
+            'otherMethods' => $otherMethods,
+        ]);
+    }
+}
diff --git a/app/Http/Controllers/Auth/MfaTotpController.php b/app/Http/Controllers/Auth/MfaTotpController.php
new file mode 100644 (file)
index 0000000..a4cb631
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\Mfa\MfaSession;
+use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Auth\Access\Mfa\TotpService;
+use BookStack\Auth\Access\Mfa\TotpValidationRule;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+class MfaTotpController extends Controller
+{
+    use HandlesPartialLogins;
+
+    protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
+
+    /**
+     * Show a view that generates and displays a TOTP QR code.
+     */
+    public function generate(TotpService $totp)
+    {
+        if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
+            $totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
+        } else {
+            $totpSecret = $totp->generateSecret();
+            session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
+        }
+
+        $qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
+        $svg = $totp->generateQrCodeSvg($qrCodeUrl);
+
+        $this->setPageTitle(trans('auth.mfa_gen_totp_title'));
+
+        return view('mfa.totp-generate', [
+            'url' => $qrCodeUrl,
+            'svg' => $svg,
+        ]);
+    }
+
+    /**
+     * Confirm the setup of TOTP and save the auth method secret
+     * against the current user.
+     *
+     * @throws ValidationException
+     * @throws NotFoundException
+     */
+    public function confirm(Request $request)
+    {
+        $totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
+        $this->validate($request, [
+            'code' => [
+                'required',
+                'max:12', 'min:4',
+                new TotpValidationRule($totpSecret),
+            ],
+        ]);
+
+        MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_TOTP, $totpSecret);
+        session()->remove(static::SETUP_SECRET_SESSION_KEY);
+        $this->logActivity(ActivityType::MFA_SETUP_METHOD, 'totp');
+
+        if (!auth()->check()) {
+            $this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
+
+            return redirect('/login');
+        }
+
+        return redirect('/mfa/setup');
+    }
+
+    /**
+     * Verify the MFA method submission on check.
+     *
+     * @throws NotFoundException
+     */
+    public function verify(Request $request, LoginService $loginService, MfaSession $mfaSession)
+    {
+        $user = $this->currentOrLastAttemptedUser();
+        $totpSecret = MfaValue::getValueForUser($user, MfaValue::METHOD_TOTP);
+
+        $this->validate($request, [
+            'code' => [
+                'required',
+                'max:12', 'min:4',
+                new TotpValidationRule($totpSecret),
+            ],
+        ]);
+
+        $mfaSession->markVerifiedForUser($user);
+        $loginService->reattemptLoginFor($user);
+
+        return redirect()->intended();
+    }
+}
diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php
new file mode 100644 (file)
index 0000000..ff93dd8
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Auth\Access\Oidc\OidcService;
+use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+
+class OidcController extends Controller
+{
+    protected $oidcService;
+
+    /**
+     * OpenIdController constructor.
+     */
+    public function __construct(OidcService $oidcService)
+    {
+        $this->oidcService = $oidcService;
+        $this->middleware('guard:oidc');
+    }
+
+    /**
+     * Start the authorization login flow via OIDC.
+     */
+    public function login()
+    {
+        $loginDetails = $this->oidcService->login();
+        session()->flash('oidc_state', $loginDetails['state']);
+
+        return redirect($loginDetails['url']);
+    }
+
+    /**
+     * Authorization flow redirect callback.
+     * Processes authorization response from the OIDC Authorization Server.
+     */
+    public function callback(Request $request)
+    {
+        $storedState = session()->pull('oidc_state');
+        $responseState = $request->query('state');
+
+        if ($storedState !== $responseState) {
+            $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
+
+            return redirect('/login');
+        }
+
+        $this->oidcService->processAuthorizeResponse($request->query('code'));
+
+        return redirect()->intended();
+    }
+}
index 7d7d8732b5f1e37d1177b230f8229b9d65b9c07f..9399e8b7f53339c373278742800604a3247b5a48 100644 (file)
@@ -2,18 +2,18 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
-use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\RegistrationService;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Auth\User;
+use BookStack\Exceptions\StoppedAuthenticationException;
 use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Theme;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
 use Illuminate\Foundation\Auth\RegistersUsers;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Hash;
-use Validator;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\Password;
 
 class RegisterController extends Controller
 {
@@ -32,6 +32,7 @@ class RegisterController extends Controller
 
     protected $socialAuthService;
     protected $registrationService;
+    protected $loginService;
 
     /**
      * Where to redirect users after login / registration.
@@ -44,13 +45,17 @@ class RegisterController extends Controller
     /**
      * Create a new controller instance.
      */
-    public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
-    {
+    public function __construct(
+        SocialAuthService $socialAuthService,
+        RegistrationService $registrationService,
+        LoginService $loginService
+    ) {
         $this->middleware('guest');
         $this->middleware('guard:standard');
 
         $this->socialAuthService = $socialAuthService;
         $this->registrationService = $registrationService;
+        $this->loginService = $loginService;
 
         $this->redirectTo = url('/');
         $this->redirectPath = url('/');
@@ -64,20 +69,22 @@ class RegisterController extends Controller
     protected function validator(array $data)
     {
         return Validator::make($data, [
-            'name' => 'required|min:2|max:255',
-            'email' => 'required|email|max:255|unique:users',
-            'password' => 'required|min:8',
+            'name'     => ['required', 'min:2', 'max:255'],
+            'email'    => ['required', 'email', 'max:255', 'unique:users'],
+            'password' => ['required', Password::default()],
         ]);
     }
 
     /**
      * Show the application registration form.
+     *
      * @throws UserRegistrationException
      */
     public function getRegister()
     {
         $this->registrationService->ensureRegistrationAllowed();
         $socialDrivers = $this->socialAuthService->getActiveDrivers();
+
         return view('auth.register', [
             'socialDrivers' => $socialDrivers,
         ]);
@@ -85,7 +92,9 @@ class RegisterController extends Controller
 
     /**
      * Handle a registration request for the application.
+     *
      * @throws UserRegistrationException
+     * @throws StoppedAuthenticationException
      */
     public function postRegister(Request $request)
     {
@@ -95,30 +104,32 @@ class RegisterController extends Controller
 
         try {
             $user = $this->registrationService->registerUser($userData);
-            auth()->login($user);
-            Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
-            $this->logActivity(ActivityType::AUTH_LOGIN, $user);
+            $this->loginService->login($user, auth()->getDefaultDriver());
         } catch (UserRegistrationException $exception) {
             if ($exception->getMessage()) {
                 $this->showErrorNotification($exception->getMessage());
             }
+
             return redirect($exception->redirectLocation);
         }
 
         $this->showSuccessNotification(trans('auth.register_success'));
+
         return redirect($this->redirectPath());
     }
 
     /**
      * Create a new user instance after a valid registration.
-     * @param  array  $data
+     *
+     * @param array $data
+     *
      * @return User
      */
     protected function create(array $data)
     {
         return User::create([
-            'name' => $data['name'],
-            'email' => $data['email'],
+            'name'     => $data['name'],
+            'email'    => $data['email'],
             'password' => Hash::make($data['password']),
         ]);
     }
index 59e9ab79baa7cb146ed7582e1fc50cd88a7d9e31..a31529b119b502b2fbddc0d3779b147e628ba043 100644 (file)
@@ -40,7 +40,8 @@ class ResetPasswordController extends Controller
      * Get the response for a successful password reset.
      *
      * @param Request $request
-     * @param string $response
+     * @param string  $response
+     *
      * @return \Illuminate\Http\Response
      */
     protected function sendResetResponse(Request $request, $response)
@@ -48,6 +49,7 @@ class ResetPasswordController extends Controller
         $message = trans('auth.reset_password_success');
         $this->showSuccessNotification($message);
         $this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
+
         return redirect($this->redirectPath())
             ->with('status', trans($response));
     }
@@ -55,8 +57,9 @@ class ResetPasswordController extends Controller
     /**
      * Get the response for a failed password reset.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  string  $response
+     * @param \Illuminate\Http\Request $request
+     * @param string                   $response
+     *
      * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
      */
     protected function sendResetFailedResponse(Request $request, $response)
index dc7814c4b173055bdb07c5c4a0e298249c113e65..b84483961127fb0bd5ee9bed41957499df4986f8 100644 (file)
@@ -4,10 +4,11 @@ namespace BookStack\Http\Controllers\Auth;
 
 use BookStack\Auth\Access\Saml2Service;
 use BookStack\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
 
 class Saml2Controller extends Controller
 {
-
     protected $samlService;
 
     /**
@@ -35,7 +36,7 @@ class Saml2Controller extends Controller
      */
     public function logout()
     {
-        $logoutDetails = $this->samlService->logout();
+        $logoutDetails = $this->samlService->logout(auth()->user());
 
         if ($logoutDetails['id']) {
             session()->flash('saml2_logout_request_id', $logoutDetails['id']);
@@ -50,8 +51,9 @@ class Saml2Controller extends Controller
     public function metadata()
     {
         $metaData = $this->samlService->metadata();
+
         return response()->make($metaData, 200, [
-            'Content-Type' => 'text/xml'
+            'Content-Type' => 'text/xml',
         ]);
     }
 
@@ -63,20 +65,61 @@ class Saml2Controller extends Controller
     {
         $requestId = session()->pull('saml2_logout_request_id', null);
         $redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
+
         return redirect($redirect);
     }
 
     /**
-     * Assertion Consumer Service.
-     * Processes the SAML response from the IDP.
+     * Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP.
+     * Due to being an external POST request, we likely won't have context of the
+     * current user session due to lax cookies. To work around this we store the
+     * SAMLResponse data and redirect to the processAcs endpoint for the actual
+     * processing of the request with proper context of the user session.
      */
-    public function acs()
+    public function startAcs(Request $request)
     {
+        $samlResponse = $request->get('SAMLResponse', null);
+
+        if (empty($samlResponse)) {
+            $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+
+            return redirect('/login');
+        }
+
+        $acsId = Str::random(16);
+        $cacheKey = 'saml2_acs:' . $acsId;
+        cache()->set($cacheKey, encrypt($samlResponse), 10);
+
+        return redirect()->guest('/saml2/acs?id=' . $acsId);
+    }
+
+    /**
+     * Assertion Consumer Service process endpoint.
+     * Processes the SAML response from the IDP with context of the current session.
+     * Takes the SAML request from the cache, added by the startAcs method above.
+     */
+    public function processAcs(Request $request)
+    {
+        $acsId = $request->get('id', null);
+        $cacheKey = 'saml2_acs:' . $acsId;
+        $samlResponse = null;
+
+        try {
+            $samlResponse = decrypt(cache()->pull($cacheKey));
+        } catch (\Exception $exception) {
+        }
         $requestId = session()->pull('saml2_request_id', null);
 
-        $user = $this->samlService->processAcsResponse($requestId);
-        if ($user === null) {
+        if (empty($acsId) || empty($samlResponse)) {
             $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+
+            return redirect('/login');
+        }
+
+        $user = $this->samlService->processAcsResponse($requestId, $samlResponse);
+        if (is_null($user)) {
+            $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+
             return redirect('/login');
         }
 
index 447f0afc9358f85eb8e7975ea08930878c332c1a..1691668a2bbc37163553167e3f20d0502c6af3cb 100644 (file)
@@ -2,48 +2,53 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
-use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\RegistrationService;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Exceptions\SocialDriverNotConfigured;
 use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use BookStack\Exceptions\SocialSignInException;
 use BookStack\Exceptions\UserRegistrationException;
-use BookStack\Facades\Theme;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use Laravel\Socialite\Contracts\User as SocialUser;
 
 class SocialController extends Controller
 {
-
     protected $socialAuthService;
     protected $registrationService;
+    protected $loginService;
 
     /**
      * SocialController constructor.
      */
-    public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
-    {
+    public function __construct(
+        SocialAuthService $socialAuthService,
+        RegistrationService $registrationService,
+        LoginService $loginService
+    ) {
         $this->middleware('guest')->only(['getRegister', 'postRegister']);
         $this->socialAuthService = $socialAuthService;
         $this->registrationService = $registrationService;
+        $this->loginService = $loginService;
     }
 
     /**
      * Redirect to the relevant social site.
+     *
      * @throws SocialDriverNotConfigured
      */
     public function login(string $socialDriver)
     {
         session()->put('social-callback', 'login');
+
         return $this->socialAuthService->startLogIn($socialDriver);
     }
 
     /**
      * Redirect to the social site for authentication intended to register.
+     *
      * @throws SocialDriverNotConfigured
      * @throws UserRegistrationException
      */
@@ -51,11 +56,13 @@ class SocialController extends Controller
     {
         $this->registrationService->ensureRegistrationAllowed();
         session()->put('social-callback', 'register');
+
         return $this->socialAuthService->startRegister($socialDriver);
     }
 
     /**
      * The callback for social login services.
+     *
      * @throws SocialSignInException
      * @throws SocialDriverNotConfigured
      * @throws UserRegistrationException
@@ -70,7 +77,7 @@ class SocialController extends Controller
         if ($request->has('error') && $request->has('error_description')) {
             throw new SocialSignInException(trans('errors.social_login_bad_response', [
                 'socialAccount' => $socialDriver,
-                'error' => $request->get('error_description'),
+                'error'         => $request->get('error_description'),
             ]), '/login');
         }
 
@@ -85,6 +92,7 @@ class SocialController extends Controller
                 if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
                     return $this->socialRegisterCallback($socialDriver, $socialUser);
                 }
+
                 throw $exception;
             }
         }
@@ -103,11 +111,13 @@ class SocialController extends Controller
     {
         $this->socialAuthService->detachSocialAccount($socialDriver);
         session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
+
         return redirect(user()->getEditUrl());
     }
 
     /**
      * Register a new user after a registration callback.
+     *
      * @throws UserRegistrationException
      */
     protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
@@ -118,9 +128,9 @@ class SocialController extends Controller
 
         // Create an array of the user data to create a new user instance
         $userData = [
-            'name' => $socialUser->getName(),
-            'email' => $socialUser->getEmail(),
-            'password' => Str::random(32)
+            'name'     => $socialUser->getName(),
+            'email'    => $socialUser->getEmail(),
+            'password' => Str::random(32),
         ];
 
         // Take name from email address if empty
@@ -129,11 +139,9 @@ class SocialController extends Controller
         }
 
         $user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
-        auth()->login($user);
-        Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $user);
-        $this->logActivity(ActivityType::AUTH_LOGIN, $user);
-
         $this->showSuccessNotification(trans('auth.register_success'));
+        $this->loginService->login($user, $socialDriver);
+
         return redirect('/');
     }
 }
index ab745224836d4e82477e0513999d049c07a4528f..27b20f831b956d67b6501a4c17a8ee902e645a85 100644 (file)
@@ -2,18 +2,16 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
-use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\UserTokenExpiredException;
 use BookStack\Exceptions\UserTokenNotFoundException;
-use BookStack\Facades\Theme;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Theming\ThemeEvents;
 use Exception;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Redirector;
+use Illuminate\Validation\Rules\Password;
 
 class UserInviteController extends Controller
 {
@@ -34,6 +32,7 @@ class UserInviteController extends Controller
 
     /**
      * Show the page for the user to set the password for their account.
+     *
      * @throws Exception
      */
     public function showSetPassword(string $token)
@@ -51,12 +50,13 @@ class UserInviteController extends Controller
 
     /**
      * Sets the password for an invited user and then grants them access.
+     *
      * @throws Exception
      */
     public function setPassword(Request $request, string $token)
     {
         $this->validate($request, [
-            'password' => 'required|min:8'
+            'password' => ['required', Password::default()],
         ]);
 
         try {
@@ -70,19 +70,18 @@ class UserInviteController extends Controller
         $user->email_confirmed = true;
         $user->save();
 
-        auth()->login($user);
-        Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
-        $this->logActivity(ActivityType::AUTH_LOGIN, $user);
-        $this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
         $this->inviteService->deleteByUser($user);
+        $this->showSuccessNotification(trans('auth.user_invite_success_login', ['appName' => setting('app-name')]));
 
-        return redirect('/');
+        return redirect('/login');
     }
 
     /**
      * Check and validate the exception thrown when checking an invite token.
-     * @return RedirectResponse|Redirector
+     *
      * @throws Exception
+     *
+     * @return RedirectResponse|Redirector
      */
     protected function handleTokenException(Exception $exception)
     {
@@ -92,6 +91,7 @@ class UserInviteController extends Controller
 
         if ($exception instanceof UserTokenExpiredException) {
             $this->showErrorNotification(trans('errors.invite_token_expired'));
+
             return redirect('/password/email');
         }
 
index 59c205d0a9241770e6fd84c8d09e0f5f196cfb3b..bc403c6d04495eaf1adf28d4895b9b7f9c6bfeab 100644 (file)
@@ -1,21 +1,25 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
-use Activity;
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityQueries;
 use BookStack\Actions\ActivityType;
-use BookStack\Entities\Tools\BookContents;
+use BookStack\Actions\View;
 use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\Cloner;
 use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Entities\Tools\ShelfContext;
-use BookStack\Entities\Repos\BookRepo;
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\Exceptions\NotFoundException;
+use BookStack\Facades\Activity;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
-use Views;
 
 class BookController extends Controller
 {
-
     protected $bookRepo;
     protected $entityContextManager;
 
@@ -42,14 +46,15 @@ class BookController extends Controller
         $this->entityContextManager->clearShelfContext();
 
         $this->setPageTitle(trans('entities.books'));
+
         return view('books.index', [
-            'books' => $books,
+            'books'   => $books,
             'recents' => $recents,
             'popular' => $popular,
-            'new' => $new,
-            'view' => $view,
-            'sort' => $sort,
-            'order' => $order,
+            'new'     => $new,
+            'view'    => $view,
+            'sort'    => $sort,
+            'order'   => $order,
         ]);
     }
 
@@ -67,13 +72,15 @@ class BookController extends Controller
         }
 
         $this->setPageTitle(trans('entities.books_create'));
+
         return view('books.create', [
-            'bookshelf' => $bookshelf
+            'bookshelf' => $bookshelf,
         ]);
     }
 
     /**
      * Store a newly created book in storage.
+     *
      * @throws ImageUploadException
      * @throws ValidationException
      */
@@ -81,9 +88,9 @@ class BookController extends Controller
     {
         $this->checkPermission('book-create-all');
         $this->validate($request, [
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
         $bookshelf = null;
@@ -97,7 +104,7 @@ class BookController extends Controller
 
         if ($bookshelf) {
             $bookshelf->appendBook($book);
-            Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
+            Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf);
         }
 
         return redirect($book->getUrl());
@@ -106,24 +113,25 @@ class BookController extends Controller
     /**
      * Display the specified book.
      */
-    public function show(Request $request, string $slug)
+    public function show(Request $request, ActivityQueries $activities, string $slug)
     {
         $book = $this->bookRepo->getBySlug($slug);
         $bookChildren = (new BookContents($book))->getTree(true);
-        $bookParentShelves = $book->shelves()->visible()->get();
+        $bookParentShelves = $book->shelves()->scopes('visible')->get();
 
-        Views::add($book);
+        View::incrementFor($book);
         if ($request->has('shelf')) {
             $this->entityContextManager->setShelfContext(intval($request->get('shelf')));
         }
 
         $this->setPageTitle($book->getShortName());
+
         return view('books.show', [
-            'book' => $book,
-            'current' => $book,
-            'bookChildren' => $bookChildren,
+            'book'              => $book,
+            'current'           => $book,
+            'bookChildren'      => $bookChildren,
             'bookParentShelves' => $bookParentShelves,
-            'activity' => Activity::entityActivity($book, 20, 1)
+            'activity'          => $activities->entityActivity($book, 20, 1),
         ]);
     }
 
@@ -135,11 +143,13 @@ class BookController extends Controller
         $book = $this->bookRepo->getBySlug($slug);
         $this->checkOwnablePermission('book-update', $book);
         $this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
+
         return view('books.edit', ['book' => $book, 'current' => $book]);
     }
 
     /**
      * Update the specified book in storage.
+     *
      * @throws ImageUploadException
      * @throws ValidationException
      * @throws Throwable
@@ -149,9 +159,9 @@ class BookController extends Controller
         $book = $this->bookRepo->getBySlug($slug);
         $this->checkOwnablePermission('book-update', $book);
         $this->validate($request, [
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
         $book = $this->bookRepo->update($book, $request->all());
@@ -169,11 +179,13 @@ class BookController extends Controller
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('book-delete', $book);
         $this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
+
         return view('books.delete', ['book' => $book, 'current' => $book]);
     }
 
     /**
      * Remove the specified book from the system.
+     *
      * @throws Throwable
      */
     public function destroy(string $bookSlug)
@@ -201,6 +213,7 @@ class BookController extends Controller
 
     /**
      * Set the restrictions for this book.
+     *
      * @throws Throwable
      */
     public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug)
@@ -211,6 +224,42 @@ class BookController extends Controller
         $permissionsUpdater->updateFromPermissionsForm($book, $request);
 
         $this->showSuccessNotification(trans('entities.books_permissions_updated'));
+
         return redirect($book->getUrl());
     }
+
+    /**
+     * Show the view to copy a book.
+     *
+     * @throws NotFoundException
+     */
+    public function showCopy(string $bookSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $this->checkOwnablePermission('book-view', $book);
+
+        session()->flashInput(['name' => $book->name]);
+
+        return view('books.copy', [
+            'book' => $book,
+        ]);
+    }
+
+    /**
+     * Create a copy of a book within the requested target destination.
+     *
+     * @throws NotFoundException
+     */
+    public function copy(Request $request, Cloner $cloner, string $bookSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $this->checkOwnablePermission('book-view', $book);
+        $this->checkPermission('book-create-all');
+
+        $newName = $request->get('name') ?: $book->name;
+        $bookCopy = $cloner->cloneBook($book, $newName);
+        $this->showSuccessNotification(trans('entities.books_copy_success'));
+
+        return redirect($bookCopy->getUrl());
+    }
 }
index 1c1f124422f962020d31e4f35595b2e65d0f80cf..7f6dd801752b5e3901cb6188e3dc200888f32984 100644 (file)
@@ -2,13 +2,12 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\ExportFormatter;
 use Throwable;
 
 class BookExportController extends Controller
 {
-
     protected $bookRepo;
     protected $exportFormatter;
 
@@ -19,27 +18,32 @@ class BookExportController extends Controller
     {
         $this->bookRepo = $bookRepo;
         $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Export a book as a PDF file.
+     *
      * @throws Throwable
      */
     public function pdf(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $pdfContent = $this->exportFormatter->bookToPdf($book);
+
         return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
     }
 
     /**
      * Export a book as a contained HTML file.
+     *
      * @throws Throwable
      */
     public function html(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
+
         return $this->downloadResponse($htmlContent, $bookSlug . '.html');
     }
 
@@ -50,6 +54,18 @@ class BookExportController extends Controller
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $textContent = $this->exportFormatter->bookToPlainText($book);
+
         return $this->downloadResponse($textContent, $bookSlug . '.txt');
     }
+
+    /**
+     * Export a book as a markdown file.
+     */
+    public function markdown(string $bookSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $textContent = $this->exportFormatter->bookToMarkdown($book);
+
+        return $this->downloadResponse($textContent, $bookSlug . '.md');
+    }
 }
index 6d3199cbee990fe90b484c2b3a3bc6d4c73a54b8..8aac2b76934cd9ca85c0e7b46cb5f5feac049e36 100644 (file)
@@ -3,16 +3,14 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Repos\BookRepo;
-use BookStack\Exceptions\SortOperationException;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\BookSortMap;
 use BookStack\Facades\Activity;
 use Illuminate\Http\Request;
 
 class BookSortController extends Controller
 {
-
     protected $bookRepo;
 
     public function __construct(BookRepo $bookRepo)
@@ -31,6 +29,7 @@ class BookSortController extends Controller
         $bookChildren = (new BookContents($book))->getTree(false);
 
         $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
+
         return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
     }
 
@@ -42,7 +41,8 @@ class BookSortController extends Controller
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $bookChildren = (new BookContents($book))->getTree();
-        return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
+
+        return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
     }
 
     /**
@@ -58,20 +58,14 @@ class BookSortController extends Controller
             return redirect($book->getUrl());
         }
 
-        $sortMap = collect(json_decode($request->get('sort-tree')));
+        $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
         $bookContents = new BookContents($book);
-        $booksInvolved = collect();
-
-        try {
-            $booksInvolved = $bookContents->sortUsingMap($sortMap);
-        } catch (SortOperationException $exception) {
-            $this->showPermissionError();
-        }
+        $booksInvolved = $bookContents->sortUsingMap($sortMap);
 
         // Rebuild permissions and add activity for involved books.
-        $booksInvolved->each(function (Book $book) {
-            Activity::addForEntity($book, ActivityType::BOOK_SORT);
-        });
+        foreach ($booksInvolved as $bookInvolved) {
+            Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
+        }
 
         return redirect($book->getUrl());
     }
index 03b3cad54cc0a538fd8de9794e3d647b2b4dca0b..9a7f78a85a0417fc70b3ea707ea495397577e1eb 100644 (file)
@@ -1,21 +1,22 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
-use Activity;
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityQueries;
+use BookStack\Actions\View;
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Entities\Tools\ShelfContext;
-use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Uploads\ImageRepo;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
-use Views;
 
 class BookshelfController extends Controller
 {
-
     protected $bookshelfRepo;
     protected $entityContextManager;
     protected $imageRepo;
@@ -36,7 +37,7 @@ class BookshelfController extends Controller
         $sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
         $order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
         $sortOptions = [
-            'name' => trans('common.sort_name'),
+            'name'       => trans('common.sort_name'),
             'created_at' => trans('common.sort_created_at'),
             'updated_at' => trans('common.sort_updated_at'),
         ];
@@ -48,14 +49,15 @@ class BookshelfController extends Controller
 
         $this->entityContextManager->clearShelfContext();
         $this->setPageTitle(trans('entities.shelves'));
+
         return view('shelves.index', [
-            'shelves' => $shelves,
-            'recents' => $recents,
-            'popular' => $popular,
-            'new' => $new,
-            'view' => $view,
-            'sort' => $sort,
-            'order' => $order,
+            'shelves'     => $shelves,
+            'recents'     => $recents,
+            'popular'     => $popular,
+            'new'         => $new,
+            'view'        => $view,
+            'sort'        => $sort,
+            'order'       => $order,
             'sortOptions' => $sortOptions,
         ]);
     }
@@ -68,11 +70,13 @@ class BookshelfController extends Controller
         $this->checkPermission('bookshelf-create-all');
         $books = Book::hasPermission('update')->get();
         $this->setPageTitle(trans('entities.shelves_create'));
+
         return view('shelves.create', ['books' => $books]);
     }
 
     /**
      * Store a newly created bookshelf in storage.
+     *
      * @throws ValidationException
      * @throws ImageUploadException
      */
@@ -80,9 +84,9 @@ class BookshelfController extends Controller
     {
         $this->checkPermission('bookshelf-create-all');
         $this->validate($request, [
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
         $bookIds = explode(',', $request->get('books', ''));
@@ -94,9 +98,10 @@ class BookshelfController extends Controller
 
     /**
      * Display the bookshelf of the given slug.
+     *
      * @throws NotFoundException
      */
-    public function show(string $slug)
+    public function show(ActivityQueries $activities, string $slug)
     {
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('book-view', $shelf);
@@ -109,18 +114,19 @@ class BookshelfController extends Controller
             ->values()
             ->all();
 
-        Views::add($shelf);
+        View::incrementFor($shelf);
         $this->entityContextManager->setShelfContext($shelf->id);
         $view = setting()->getForCurrentUser('bookshelf_view_type');
 
         $this->setPageTitle($shelf->getShortName());
+
         return view('shelves.show', [
-            'shelf' => $shelf,
+            'shelf'                   => $shelf,
             'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
-            'view' => $view,
-            'activity' => Activity::entityActivity($shelf, 20, 1),
-            'order' => $order,
-            'sort' => $sort
+            'view'                    => $view,
+            'activity'                => $activities->entityActivity($shelf, 20, 1),
+            'order'                   => $order,
+            'sort'                    => $sort,
         ]);
     }
 
@@ -136,6 +142,7 @@ class BookshelfController extends Controller
         $books = Book::hasPermission('update')->whereNotIn('id', $shelfBookIds)->get();
 
         $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
+
         return view('shelves.edit', [
             'shelf' => $shelf,
             'books' => $books,
@@ -144,6 +151,7 @@ class BookshelfController extends Controller
 
     /**
      * Update the specified bookshelf in storage.
+     *
      * @throws ValidationException
      * @throws ImageUploadException
      * @throws NotFoundException
@@ -153,12 +161,11 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-update', $shelf);
         $this->validate($request, [
-            'name' => 'required|string|max:255',
-            'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'name'        => ['required', 'string', 'max:255'],
+            'description' => ['string', 'max:1000'],
+            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
-
         $bookIds = explode(',', $request->get('books', ''));
         $shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds);
         $resetCover = $request->has('image_reset');
@@ -168,7 +175,7 @@ class BookshelfController extends Controller
     }
 
     /**
-     * Shows the page to confirm deletion
+     * Shows the page to confirm deletion.
      */
     public function showDelete(string $slug)
     {
@@ -176,11 +183,13 @@ class BookshelfController extends Controller
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
         $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
+
         return view('shelves.delete', ['shelf' => $shelf]);
     }
 
     /**
      * Remove the specified bookshelf from storage.
+     *
      * @throws Exception
      */
     public function destroy(string $slug)
@@ -217,6 +226,7 @@ class BookshelfController extends Controller
         $permissionsUpdater->updateFromPermissionsForm($shelf, $request);
 
         $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
+
         return redirect($shelf->getUrl());
     }
 
@@ -230,6 +240,7 @@ class BookshelfController extends Controller
 
         $updateCount = $this->bookshelfRepo->copyDownPermissions($shelf);
         $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
+
         return redirect($shelf->getUrl());
     }
 }
index 1d69df2a2f6029e148ff59a4f1bb59f04678d8b0..83b9bb692da6c3173305c538ab79a6a8a836da86 100644 (file)
@@ -1,19 +1,23 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\View;
 use BookStack\Entities\Models\Book;
-use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\Cloner;
+use BookStack\Entities\Tools\NextPreviousContentLocator;
 use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\PermissionsException;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
-use Views;
 
 class ChapterController extends Controller
 {
-
     protected $chapterRepo;
 
     /**
@@ -33,17 +37,19 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-create', $book);
 
         $this->setPageTitle(trans('entities.chapters_create'));
+
         return view('chapters.create', ['book' => $book, 'current' => $book]);
     }
 
     /**
      * Store a newly created chapter in storage.
+     *
      * @throws ValidationException
      */
     public function store(Request $request, string $bookSlug)
     {
         $this->validate($request, [
-            'name' => 'required|string|max:255'
+            'name' => ['required', 'string', 'max:255'],
         ]);
 
         $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
@@ -64,15 +70,19 @@ class ChapterController extends Controller
 
         $sidebarTree = (new BookContents($chapter->book))->getTree();
         $pages = $chapter->getVisiblePages();
-        Views::add($chapter);
+        $nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree);
+        View::incrementFor($chapter);
 
         $this->setPageTitle($chapter->getShortName());
+
         return view('chapters.show', [
-            'book' => $chapter->book,
-            'chapter' => $chapter,
-            'current' => $chapter,
+            'book'        => $chapter->book,
+            'chapter'     => $chapter,
+            'current'     => $chapter,
             'sidebarTree' => $sidebarTree,
-            'pages' => $pages
+            'pages'       => $pages,
+            'next'        => $nextPreviousLocator->getNext(),
+            'previous'    => $nextPreviousLocator->getPrevious(),
         ]);
     }
 
@@ -85,11 +95,13 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-update', $chapter);
 
         $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
+
         return view('chapters.edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
     }
 
     /**
      * Update the specified chapter in storage.
+     *
      * @throws NotFoundException
      */
     public function update(Request $request, string $bookSlug, string $chapterSlug)
@@ -104,6 +116,7 @@ class ChapterController extends Controller
 
     /**
      * Shows the page to confirm deletion of this chapter.
+     *
      * @throws NotFoundException
      */
     public function showDelete(string $bookSlug, string $chapterSlug)
@@ -112,11 +125,13 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
         $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
+
         return view('chapters.delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
     }
 
     /**
      * Remove the specified chapter from storage.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -132,6 +147,7 @@ class ChapterController extends Controller
 
     /**
      * Show the page for moving a chapter.
+     *
      * @throws NotFoundException
      */
     public function showMove(string $bookSlug, string $chapterSlug)
@@ -143,12 +159,13 @@ class ChapterController extends Controller
 
         return view('chapters.move', [
             'chapter' => $chapter,
-            'book' => $chapter->book
+            'book'    => $chapter->book,
         ]);
     }
 
     /**
      * Perform the move action for a chapter.
+     *
      * @throws NotFoundException
      */
     public function move(Request $request, string $bookSlug, string $chapterSlug)
@@ -164,17 +181,69 @@ class ChapterController extends Controller
 
         try {
             $newBook = $this->chapterRepo->move($chapter, $entitySelection);
+        } catch (PermissionsException $exception) {
+            $this->showPermissionError();
         } catch (MoveOperationException $exception) {
             $this->showErrorNotification(trans('errors.selected_book_not_found'));
+
             return redirect()->back();
         }
 
         $this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
+
         return redirect($chapter->getUrl());
     }
 
+    /**
+     * Show the view to copy a chapter.
+     *
+     * @throws NotFoundException
+     */
+    public function showCopy(string $bookSlug, string $chapterSlug)
+    {
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
+        $this->checkOwnablePermission('chapter-view', $chapter);
+
+        session()->flashInput(['name' => $chapter->name]);
+
+        return view('chapters.copy', [
+            'book'    => $chapter->book,
+            'chapter' => $chapter,
+        ]);
+    }
+
+    /**
+     * Create a copy of a chapter within the requested target destination.
+     *
+     * @throws NotFoundException
+     * @throws Throwable
+     */
+    public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
+    {
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
+        $this->checkOwnablePermission('chapter-view', $chapter);
+
+        $entitySelection = $request->get('entity_selection') ?: null;
+        $newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
+
+        if (is_null($newParentBook)) {
+            $this->showErrorNotification(trans('errors.selected_book_not_found'));
+
+            return redirect()->back();
+        }
+
+        $this->checkOwnablePermission('chapter-create', $newParentBook);
+
+        $newName = $request->get('name') ?: $chapter->name;
+        $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
+        $this->showSuccessNotification(trans('entities.chapters_copy_success'));
+
+        return redirect($chapterCopy->getUrl());
+    }
+
     /**
      * Show the Restrictions view.
+     *
      * @throws NotFoundException
      */
     public function showPermissions(string $bookSlug, string $chapterSlug)
@@ -189,6 +258,7 @@ class ChapterController extends Controller
 
     /**
      * Set the restrictions for this chapter.
+     *
      * @throws NotFoundException
      */
     public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug)
@@ -199,6 +269,7 @@ class ChapterController extends Controller
         $permissionsUpdater->updateFromPermissionsForm($chapter, $request);
 
         $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
+
         return redirect($chapter->getUrl());
     }
 }
index 52d087442ab287eb2d533365ed6cfd8cce5da642..480280c99ef6dc83a169ba1762d5e4fc0edd4d36 100644 (file)
@@ -1,13 +1,14 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Exceptions\NotFoundException;
 use Throwable;
 
 class ChapterExportController extends Controller
 {
-
     protected $chapterRepo;
     protected $exportFormatter;
 
@@ -18,10 +19,12 @@ class ChapterExportController extends Controller
     {
         $this->chapterRepo = $chapterRepo;
         $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Exports a chapter to pdf.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -29,11 +32,13 @@ class ChapterExportController extends Controller
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
+
         return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
     }
 
     /**
      * Export a chapter to a self-contained HTML file.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -41,17 +46,34 @@ class ChapterExportController extends Controller
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
+
         return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
     }
 
     /**
      * Export a chapter to a simple plaintext .txt file.
+     *
      * @throws NotFoundException
      */
     public function plainText(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $chapterText = $this->exportFormatter->chapterToPlainText($chapter);
+
         return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
     }
+
+    /**
+     * Export a chapter to a simple markdown file.
+     *
+     * @throws NotFoundException
+     */
+    public function markdown(string $bookSlug, string $chapterSlug)
+    {
+        // TODO: This should probably export to a zip file.
+        $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
+        $chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
+
+        return $this->downloadResponse($chapterText, $chapterSlug . '.md');
+    }
 }
index bf1a76f518f3ce70f2792bd1138bc309ca961d4d..9804f6d39a0b077448a84a887b8563c6826a6047 100644 (file)
@@ -1,7 +1,7 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
-use Activity;
-use BookStack\Actions\ActivityType;
 use BookStack\Actions\CommentRepo;
 use BookStack\Entities\Models\Page;
 use Illuminate\Http\Request;
@@ -17,14 +17,15 @@ class CommentController extends Controller
     }
 
     /**
-     * Save a new comment for a Page
+     * Save a new comment for a Page.
+     *
      * @throws ValidationException
      */
     public function savePageComment(Request $request, int $pageId)
     {
         $this->validate($request, [
-            'text' => 'required|string',
-            'parent_id' => 'nullable|integer',
+            'text'      => ['required', 'string'],
+            'parent_id' => ['nullable', 'integer'],
         ]);
 
         $page = Page::visible()->find($pageId);
@@ -40,17 +41,19 @@ class CommentController extends Controller
         // Create a new comment.
         $this->checkPermission('comment-create-all');
         $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
+
         return view('comments.comment', ['comment' => $comment]);
     }
 
     /**
      * Update an existing comment.
+     *
      * @throws ValidationException
      */
     public function update(Request $request, int $commentId)
     {
         $this->validate($request, [
-            'text' => 'required|string',
+            'text' => ['required', 'string'],
         ]);
 
         $comment = $this->commentRepo->getById($commentId);
@@ -58,6 +61,7 @@ class CommentController extends Controller
         $this->checkOwnablePermission('comment-update', $comment);
 
         $comment = $this->commentRepo->update($comment, $request->get('text'));
+
         return view('comments.comment', ['comment' => $comment]);
     }
 
@@ -70,6 +74,7 @@ class CommentController extends Controller
         $this->checkOwnablePermission('comment-delete', $comment);
 
         $this->commentRepo->delete($comment);
+
         return response()->json(['message' => trans('entities.comment_deleted')]);
     }
 }
index 034dfa524192013b4766880ebb878176ef95d496..2c4c2df1e384e2b2b433062d82d82aa959c3e1c8 100644 (file)
@@ -4,8 +4,8 @@ namespace BookStack\Http\Controllers;
 
 use BookStack\Facades\Activity;
 use BookStack\Interfaces\Loggable;
-use BookStack\HasCreatorAndUpdater;
 use BookStack\Model;
+use BookStack\Util\WebSafeMimeSniffer;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\Exceptions\HttpResponseException;
@@ -15,7 +15,8 @@ use Illuminate\Routing\Controller as BaseController;
 
 abstract class Controller extends BaseController
 {
-    use DispatchesJobs, ValidatesRequests;
+    use DispatchesJobs;
+    use ValidatesRequests;
 
     /**
      * Check if the current user is signed in.
@@ -47,6 +48,8 @@ abstract class Controller extends BaseController
     /**
      * On a permission error redirect to home and display.
      * the error as a notification.
+     *
+     * @return never
      */
     protected function showPermissionError()
     {
@@ -105,7 +108,7 @@ abstract class Controller extends BaseController
     /**
      * Send back a json error message.
      */
-    protected function jsonError(string $messageText = "", int $statusCode = 500): JsonResponse
+    protected function jsonError(string $messageText = '', int $statusCode = 500): JsonResponse
     {
         return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
     }
@@ -116,8 +119,24 @@ abstract class Controller extends BaseController
     protected function downloadResponse(string $content, string $fileName): Response
     {
         return response()->make($content, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $fileName . '"'
+            'Content-Type'           => 'application/octet-stream',
+            'Content-Disposition'    => 'attachment; filename="' . $fileName . '"',
+            'X-Content-Type-Options' => 'nosniff',
+        ]);
+    }
+
+    /**
+     * Create a file download response that provides the file with a content-type
+     * correct for the file, in a way so the browser can show the content in browser.
+     */
+    protected function inlineDownloadResponse(string $content, string $fileName): Response
+    {
+        $mime = (new WebSafeMimeSniffer())->sniff($content);
+
+        return response()->make($content, 200, [
+            'Content-Type'           => $mime,
+            'Content-Disposition'    => 'inline; filename="' . $fileName . '"',
+            'X-Content-Type-Options' => 'nosniff',
         ]);
     }
 
@@ -147,7 +166,8 @@ abstract class Controller extends BaseController
 
     /**
      * Log an activity in the system.
-     * @param string|Loggable
+     *
+     * @param string|Loggable $detail
      */
     protected function logActivity(string $type, $detail = ''): void
     {
@@ -157,8 +177,8 @@ abstract class Controller extends BaseController
     /**
      * Get the validation rules for image files.
      */
-    protected function getImageValidationRules(): string
+    protected function getImageValidationRules(): array
     {
-        return 'image_extension|mimes:jpeg,png,gif,webp';
+        return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
     }
 }
diff --git a/app/Http/Controllers/FavouriteController.php b/app/Http/Controllers/FavouriteController.php
new file mode 100644 (file)
index 0000000..b4cbdf5
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Queries\TopFavourites;
+use BookStack\Interfaces\Favouritable;
+use BookStack\Model;
+use Illuminate\Http\Request;
+
+class FavouriteController extends Controller
+{
+    /**
+     * Show a listing of all favourite items for the current user.
+     */
+    public function index(Request $request)
+    {
+        $viewCount = 20;
+        $page = intval($request->get('page', 1));
+        $favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
+
+        $hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
+
+        $this->setPageTitle(trans('entities.my_favourites'));
+
+        return view('common.detailed-listing-with-more', [
+            'title'       => trans('entities.my_favourites'),
+            'entities'    => $favourites->slice(0, $viewCount),
+            'hasMoreLink' => $hasMoreLink,
+        ]);
+    }
+
+    /**
+     * Add a new item as a favourite.
+     */
+    public function add(Request $request)
+    {
+        $favouritable = $this->getValidatedModelFromRequest($request);
+        $favouritable->favourites()->firstOrCreate([
+            'user_id' => user()->id,
+        ]);
+
+        $this->showSuccessNotification(trans('activities.favourite_add_notification', [
+            'name' => $favouritable->name,
+        ]));
+
+        return redirect()->back();
+    }
+
+    /**
+     * Remove an item as a favourite.
+     */
+    public function remove(Request $request)
+    {
+        $favouritable = $this->getValidatedModelFromRequest($request);
+        $favouritable->favourites()->where([
+            'user_id' => user()->id,
+        ])->delete();
+
+        $this->showSuccessNotification(trans('activities.favourite_remove_notification', [
+            'name' => $favouritable->name,
+        ]));
+
+        return redirect()->back();
+    }
+
+    /**
+     * @throws \Illuminate\Validation\ValidationException
+     * @throws \Exception
+     */
+    protected function getValidatedModelFromRequest(Request $request): Entity
+    {
+        $modelInfo = $this->validate($request, [
+            'type' => ['required', 'string'],
+            'id'   => ['required', 'integer'],
+        ]);
+
+        if (!class_exists($modelInfo['type'])) {
+            throw new \Exception('Model not found');
+        }
+
+        /** @var Model $model */
+        $model = new $modelInfo['type']();
+        if (!$model instanceof Favouritable) {
+            throw new \Exception('Model not favouritable');
+        }
+
+        $modelInstance = $model->newQuery()
+            ->where('id', '=', $modelInfo['id'])
+            ->first(['id', 'name']);
+
+        $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
+        if (is_null($modelInstance) || $inaccessibleEntity) {
+            throw new \Exception('Model instance not found');
+        }
+
+        return $modelInstance;
+    }
+}
index 1ffb99f8d427a00672683af60c35f6061e015705..9e66a064077d94749bdf99a2c0e8a9eecdd45bf2 100644 (file)
@@ -1,23 +1,24 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
-use Activity;
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityQueries;
 use BookStack\Entities\Models\Book;
-use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Queries\RecentlyViewed;
+use BookStack\Entities\Queries\TopFavourites;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\BookshelfRepo;
-use Illuminate\Http\Response;
-use Views;
+use BookStack\Entities\Tools\PageContent;
 
 class HomeController extends Controller
 {
-
     /**
      * Display the homepage.
      */
-    public function index()
+    public function index(ActivityQueries $activities)
     {
-        $activity = Activity::latest(10);
+        $activity = $activities->latest(10);
         $draftPages = [];
 
         if ($this->isSignedIn()) {
@@ -32,12 +33,14 @@ class HomeController extends Controller
 
         $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
         $recents = $this->isSignedIn() ?
-              Views::getUserRecentlyViewed(12*$recentFactor, 1)
+            (new RecentlyViewed())->run(12 * $recentFactor, 1)
             : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
+        $favourites = (new TopFavourites())->run(6);
         $recentlyUpdatedPages = Page::visible()->with('book')
             ->where('draft', false)
             ->orderBy('updated_at', 'desc')
-            ->take(12)
+            ->take($favourites->count() > 0 ? 5 : 10)
+            ->select(Page::$listAttributes)
             ->get();
 
         $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
@@ -47,10 +50,11 @@ class HomeController extends Controller
         }
 
         $commonData = [
-            'activity' => $activity,
-            'recents' => $recents,
+            'activity'             => $activity,
+            'recents'              => $recents,
             'recentlyUpdatedPages' => $recentlyUpdatedPages,
-            'draftPages' => $draftPages,
+            'draftPages'           => $draftPages,
+            'favourites'           => $favourites,
         ];
 
         // Add required list ordering & sorting for books & shelves views.
@@ -61,15 +65,15 @@ class HomeController extends Controller
             $order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
 
             $sortOptions = [
-                'name' => trans('common.sort_name'),
+                'name'       => trans('common.sort_name'),
                 'created_at' => trans('common.sort_created_at'),
                 'updated_at' => trans('common.sort_updated_at'),
             ];
 
             $commonData = array_merge($commonData, [
-                'view' => $view,
-                'sort' => $sort,
-                'order' => $order,
+                'view'        => $view,
+                'sort'        => $sort,
+                'order'       => $order,
                 'sortOptions' => $sortOptions,
             ]);
         }
@@ -77,41 +81,44 @@ class HomeController extends Controller
         if ($homepageOption === 'bookshelves') {
             $shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
             $data = array_merge($commonData, ['shelves' => $shelves]);
-            return view('common.home-shelves', $data);
+
+            return view('home.shelves', $data);
         }
 
         if ($homepageOption === 'books') {
             $bookRepo = app(BookRepo::class);
             $books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
             $data = array_merge($commonData, ['books' => $books]);
-            return view('common.home-book', $data);
+
+            return view('home.books', $data);
         }
 
         if ($homepageOption === 'page') {
             $homepageSetting = setting('app-homepage', '0:');
             $id = intval(explode(':', $homepageSetting)[0]);
+            /** @var Page $customHomepage */
             $customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
             $pageContent = new PageContent($customHomepage);
-            $customHomepage->html = $pageContent->render(true);
-            return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage]));
+            $customHomepage->html = $pageContent->render(false);
+
+            return view('home.specific-page', array_merge($commonData, ['customHomepage' => $customHomepage]));
         }
 
-        return view('common.home', $commonData);
+        return view('home.default', $commonData);
     }
 
     /**
      * Get custom head HTML, Used in ajax calls to show in editor.
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
      */
     public function customHeadContent()
     {
-        return view('partials.custom-head');
+        return view('common.custom-head');
     }
 
     /**
-     * Show the view for /robots.txt
+     * Show the view for /robots.txt.
      */
-    public function getRobots()
+    public function robots()
     {
         $sitePublic = setting('app-public', false);
         $allowRobots = config('app.allow_robots');
@@ -121,14 +128,14 @@ class HomeController extends Controller
         }
 
         return response()
-            ->view('common.robots', ['allowRobots' => $allowRobots])
+            ->view('misc.robots', ['allowRobots' => $allowRobots])
             ->header('Content-Type', 'text/plain');
     }
 
     /**
      * Show the route for 404 responses.
      */
-    public function getNotFound()
+    public function notFound()
     {
         return response()->view('errors.404', [], 404);
     }
index 462ab68f6f32f66ee44ef4439d2448ff62e2c12e..cab1c925e414855a3a8be13b08e2c825dc101360 100644 (file)
@@ -3,10 +3,10 @@
 namespace BookStack\Http\Controllers\Images;
 
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\Http\Controllers\Controller;
 use BookStack\Uploads\ImageRepo;
 use Exception;
 use Illuminate\Http\Request;
-use BookStack\Http\Controllers\Controller;
 
 class DrawioImageController extends Controller
 {
@@ -29,21 +29,23 @@ class DrawioImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-        return view('components.image-manager-list', [
-            'images' => $imgData['images'],
+
+        return view('pages.parts.image-manager-list', [
+            'images'  => $imgData['images'],
             'hasMore' => $imgData['has_more'],
         ]);
     }
 
     /**
      * Store a new gallery image in the system.
+     *
      * @throws Exception
      */
     public function create(Request $request)
     {
         $this->validate($request, [
-            'image' => 'required|string',
-            'uploaded_to' => 'required|integer'
+            'image'       => ['required', 'string'],
+            'uploaded_to' => ['required', 'integer'],
         ]);
 
         $this->checkPermission('image-create-all');
@@ -65,18 +67,17 @@ class DrawioImageController extends Controller
     public function getAsBase64($id)
     {
         $image = $this->imageRepo->getById($id);
-        $page = $image->getPage();
-        if ($image === null || $image->type !== 'drawio' || !userCan('page-view', $page)) {
-            return $this->jsonError("Image data could not be found");
+        if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
+            return $this->jsonError('Image data could not be found');
         }
 
         $imageData = $this->imageRepo->getImageData($image);
-        if ($imageData === null) {
-            return $this->jsonError("Image data could not be found");
+        if (is_null($imageData)) {
+            return $this->jsonError('Image data could not be found');
         }
 
         return response()->json([
-            'content' => base64_encode($imageData)
+            'content' => base64_encode($imageData),
         ]);
     }
 }
index c3ad0b7b261fe6d5946de46eacf5f263715e7470..5484411d36c4208da2055e37d9e5335689c8270a 100644 (file)
@@ -3,9 +3,9 @@
 namespace BookStack\Http\Controllers\Images;
 
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\Http\Controllers\Controller;
 use BookStack\Uploads\ImageRepo;
 use Illuminate\Http\Request;
-use BookStack\Http\Controllers\Controller;
 use Illuminate\Validation\ValidationException;
 
 class GalleryImageController extends Controller
@@ -32,21 +32,23 @@ class GalleryImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-        return view('components.image-manager-list', [
-            'images' => $imgData['images'],
+
+        return view('pages.parts.image-manager-list', [
+            'images'  => $imgData['images'],
             'hasMore' => $imgData['has_more'],
         ]);
     }
 
     /**
      * Store a new gallery image in the system.
+     *
      * @throws ValidationException
      */
     public function create(Request $request)
     {
         $this->checkPermission('image-create-all');
         $this->validate($request, [
-            'file' => $this->getImageValidationRules()
+            'file' => $this->getImageValidationRules(),
         ]);
 
         try {
index ecc36bf67e24ad531f83326ed32d22bf4f97f63d..21ed58553f84ee3a6003cf27edb859c43ff64897 100644 (file)
@@ -1,53 +1,57 @@
-<?php namespace BookStack\Http\Controllers\Images;
+<?php
+
+namespace BookStack\Http\Controllers\Images;
 
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\Exceptions\NotFoundException;
 use BookStack\Http\Controllers\Controller;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageService;
 use Exception;
-use Illuminate\Filesystem\Filesystem as File;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
 class ImageController extends Controller
 {
-    protected $image;
-    protected $file;
     protected $imageRepo;
+    protected $imageService;
 
     /**
      * ImageController constructor.
      */
-    public function __construct(Image $image, File $file, ImageRepo $imageRepo)
+    public function __construct(ImageRepo $imageRepo, ImageService $imageService)
     {
-        $this->image = $image;
-        $this->file = $file;
         $this->imageRepo = $imageRepo;
+        $this->imageService = $imageService;
     }
 
     /**
      * Provide an image file from storage.
+     *
+     * @throws NotFoundException
      */
     public function showImage(string $path)
     {
-        $path = storage_path('uploads/images/' . $path);
-        if (!file_exists($path)) {
-            abort(404);
+        if (!$this->imageService->pathExistsInLocalSecure($path)) {
+            throw (new NotFoundException(trans('errors.image_not_found')))
+                ->setSubtitle(trans('errors.image_not_found_subtitle'))
+                ->setDetails(trans('errors.image_not_found_details'));
         }
 
-        return response()->file($path);
+        return $this->imageService->streamImageFromStorageResponse('gallery', $path);
     }
 
-
     /**
-     * Update image details
+     * Update image details.
+     *
      * @throws ImageUploadException
      * @throws ValidationException
      */
     public function update(Request $request, string $id)
     {
         $this->validate($request, [
-            'name' => 'required|min:2|string'
+            'name' => ['required', 'min:2', 'string'],
         ]);
 
         $image = $this->imageRepo->getById($id);
@@ -57,14 +61,16 @@ class ImageController extends Controller
         $image = $this->imageRepo->updateImageDetails($image, $request->all());
 
         $this->imageRepo->loadThumbs($image);
-        return view('components.image-manager-form', [
-            'image' => $image,
+
+        return view('pages.parts.image-manager-form', [
+            'image'          => $image,
             'dependantPages' => null,
         ]);
     }
 
     /**
      * Get the form for editing the given image.
+     *
      * @throws Exception
      */
     public function edit(Request $request, string $id)
@@ -77,14 +83,16 @@ class ImageController extends Controller
         }
 
         $this->imageRepo->loadThumbs($image);
-        return view('components.image-manager-form', [
-            'image' => $image,
+
+        return view('pages.parts.image-manager-form', [
+            'image'          => $image,
             'dependantPages' => $dependantPages ?? null,
         ]);
     }
 
     /**
-     * Deletes an image and all thumbnail/image files
+     * Deletes an image and all thumbnail/image files.
+     *
      * @throws Exception
      */
     public function destroy(string $id)
@@ -94,6 +102,7 @@ class ImageController extends Controller
         $this->checkImagePermission($image);
 
         $this->imageRepo->destroyImage($image);
+
         return response('');
     }
 
index 3354a148cfd1f08a628b0f30c0f15d7a4f15b21b..f13266d7c6150c77b255f49e7f9131ea8ceb98a1 100644 (file)
@@ -25,7 +25,7 @@ class MaintenanceController extends Controller
         $recycleStats = (new TrashCan())->getTrashedCounts();
 
         return view('settings.maintenance', [
-            'version' => $version,
+            'version'      => $version,
             'recycleStats' => $recycleStats,
         ]);
     }
@@ -45,6 +45,7 @@ class MaintenanceController extends Controller
         $deleteCount = count($imagesToDelete);
         if ($deleteCount === 0) {
             $this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
+
             return redirect('/settings/maintenance')->withInput();
         }
 
@@ -66,7 +67,7 @@ class MaintenanceController extends Controller
         $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
 
         try {
-            user()->notify(new TestEmail());
+            user()->notifyNow(new TestEmail());
             $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
         } catch (\Exception $exception) {
             $errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
index 7d8e54382961006db647b5f1b1b4fe9982337a2d..eecb6a6e79c94d43b1f88287f3175c8c83854639 100644 (file)
@@ -1,23 +1,26 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\View;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
 use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\Cloner;
+use BookStack\Entities\Tools\NextPreviousContentLocator;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Tools\PageEditActivity;
-use BookStack\Entities\Models\Page;
-use BookStack\Entities\Repos\PageRepo;
 use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
 use Exception;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
-use Views;
 
 class PageController extends Controller
 {
-
     protected $pageRepo;
 
     /**
@@ -30,6 +33,7 @@ class PageController extends Controller
 
     /**
      * Show the form for creating a new page.
+     *
      * @throws Throwable
      */
     public function create(string $bookSlug, string $chapterSlug = null)
@@ -40,22 +44,25 @@ class PageController extends Controller
         // Redirect to draft edit screen if signed in
         if ($this->isSignedIn()) {
             $draft = $this->pageRepo->getNewDraftPage($parent);
+
             return redirect($draft->getUrl());
         }
 
         // Otherwise show the edit view if they're a guest
         $this->setPageTitle(trans('entities.pages_new'));
+
         return view('pages.guest-create', ['parent' => $parent]);
     }
 
     /**
      * Create a new page as a guest user.
+     *
      * @throws ValidationException
      */
     public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
     {
         $this->validate($request, [
-            'name' => 'required|string|max:255'
+            'name' => ['required', 'string', 'max:255'],
         ]);
 
         $parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
@@ -64,7 +71,7 @@ class PageController extends Controller
         $page = $this->pageRepo->getNewDraftPage($parent);
         $this->pageRepo->publishDraft($page, [
             'name' => $request->get('name'),
-            'html' => ''
+            'html' => '',
         ]);
 
         return redirect($page->getUrl('/edit'));
@@ -72,6 +79,7 @@ class PageController extends Controller
 
     /**
      * Show form to continue editing a draft page.
+     *
      * @throws NotFoundException
      */
     public function editDraft(string $bookSlug, int $pageId)
@@ -84,23 +92,24 @@ class PageController extends Controller
         $templates = $this->pageRepo->getTemplates(10);
 
         return view('pages.edit', [
-            'page' => $draft,
-            'book' => $draft->book,
-            'isDraft' => true,
+            'page'          => $draft,
+            'book'          => $draft->book,
+            'isDraft'       => true,
             'draftsEnabled' => $draftsEnabled,
-            'templates' => $templates,
+            'templates'     => $templates,
         ]);
     }
 
     /**
      * Store a new page by changing a draft into a page.
+     *
      * @throws NotFoundException
      * @throws ValidationException
      */
     public function store(Request $request, string $bookSlug, int $pageId)
     {
         $this->validate($request, [
-            'name' => 'required|string|max:255'
+            'name' => ['required', 'string', 'max:255'],
         ]);
         $draftPage = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-create', $draftPage->getParent());
@@ -113,6 +122,7 @@ class PageController extends Controller
     /**
      * Display the specified page.
      * If the page is not found via the slug the revisions are searched for a match.
+     *
      * @throws NotFoundException
      */
     public function show(string $bookSlug, string $pageSlug)
@@ -142,32 +152,40 @@ class PageController extends Controller
             $page->load(['comments.createdBy']);
         }
 
-        Views::add($page);
+        $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree);
+
+        View::incrementFor($page);
         $this->setPageTitle($page->getShortName());
+
         return view('pages.show', [
-            'page' => $page,
-            'book' => $page->book,
-            'current' => $page,
-            'sidebarTree' => $sidebarTree,
+            'page'            => $page,
+            'book'            => $page->book,
+            'current'         => $page,
+            'sidebarTree'     => $sidebarTree,
             'commentsEnabled' => $commentsEnabled,
-            'pageNav' => $pageNav
+            'pageNav'         => $pageNav,
+            'next'            => $nextPreviousLocator->getNext(),
+            'previous'        => $nextPreviousLocator->getPrevious(),
         ]);
     }
 
     /**
      * Get page from an ajax request.
+     *
      * @throws NotFoundException
      */
     public function getPageAjax(int $pageId)
     {
         $page = $this->pageRepo->getById($pageId);
         $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
-        $page->addHidden(['book']);
+        $page->makeHidden(['book']);
+
         return response()->json($page);
     }
 
     /**
      * Show the form for editing the specified page.
+     *
      * @throws NotFoundException
      */
     public function edit(string $bookSlug, string $pageSlug)
@@ -199,24 +217,26 @@ class PageController extends Controller
         $templates = $this->pageRepo->getTemplates(10);
         $draftsEnabled = $this->isSignedIn();
         $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()]));
+
         return view('pages.edit', [
-            'page' => $page,
-            'book' => $page->book,
-            'current' => $page,
+            'page'          => $page,
+            'book'          => $page->book,
+            'current'       => $page,
             'draftsEnabled' => $draftsEnabled,
-            'templates' => $templates,
+            'templates'     => $templates,
         ]);
     }
 
     /**
      * Update the specified page in storage.
+     *
      * @throws ValidationException
      * @throws NotFoundException
      */
     public function update(Request $request, string $bookSlug, string $pageSlug)
     {
         $this->validate($request, [
-            'name' => 'required|string|max:255'
+            'name' => ['required', 'string', 'max:255'],
         ]);
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-update', $page);
@@ -228,6 +248,7 @@ class PageController extends Controller
 
     /**
      * Save a draft update as a revision.
+     *
      * @throws NotFoundException
      */
     public function saveDraft(Request $request, int $pageId)
@@ -240,62 +261,69 @@ class PageController extends Controller
         }
 
         $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
+        $warnings = (new PageEditActivity($page))->getWarningMessagesForDraft($draft);
 
-        $updateTime = $draft->updated_at->timestamp;
         return response()->json([
             'status'    => 'success',
             'message'   => trans('entities.pages_edit_draft_save_at'),
-            'timestamp' => $updateTime
+            'warning'   => implode("\n", $warnings),
+            'timestamp' => $draft->updated_at->timestamp,
         ]);
     }
 
     /**
      * Redirect from a special link url which uses the page id rather than the name.
+     *
      * @throws NotFoundException
      */
     public function redirectFromLink(int $pageId)
     {
         $page = $this->pageRepo->getById($pageId);
+
         return redirect($page->getUrl());
     }
 
     /**
      * Show the deletion page for the specified page.
+     *
      * @throws NotFoundException
      */
     public function showDelete(string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-delete', $page);
-        $this->setPageTitle(trans('entities.pages_delete_named', ['pageName'=>$page->getShortName()]));
+        $this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
+
         return view('pages.delete', [
-            'book' => $page->book,
-            'page' => $page,
-            'current' => $page
+            'book'    => $page->book,
+            'page'    => $page,
+            'current' => $page,
         ]);
     }
 
     /**
      * Show the deletion page for the specified page.
+     *
      * @throws NotFoundException
      */
     public function showDeleteDraft(string $bookSlug, int $pageId)
     {
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
-        $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName'=>$page->getShortName()]));
+        $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
+
         return view('pages.delete', [
-            'book' => $page->book,
-            'page' => $page,
-            'current' => $page
+            'book'    => $page->book,
+            'page'    => $page,
+            'current' => $page,
         ]);
     }
 
     /**
      * Remove the specified page from storage.
+     *
      * @throws NotFoundException
      * @throws Throwable
-     * @throws NotifyException
      */
     public function destroy(string $bookSlug, string $pageSlug)
     {
@@ -310,8 +338,8 @@ class PageController extends Controller
 
     /**
      * Remove the specified draft page from storage.
+     *
      * @throws NotFoundException
-     * @throws NotifyException
      * @throws Throwable
      */
     public function destroyDraft(string $bookSlug, int $pageId)
@@ -328,6 +356,7 @@ class PageController extends Controller
         if ($chapter && userCan('view', $chapter)) {
             return redirect($chapter->getUrl());
         }
+
         return redirect($book->getUrl());
     }
 
@@ -336,18 +365,28 @@ class PageController extends Controller
      */
     public function showRecentlyUpdated()
     {
-        $pages = Page::visible()->orderBy('updated_at', 'desc')
+        $visibleBelongsScope = function (BelongsTo $query) {
+            $query->scopes('visible');
+        };
+
+        $pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
+            ->orderBy('updated_at', 'desc')
             ->paginate(20)
             ->setPath(url('/pages/recently-updated'));
 
-        return view('pages.detailed-listing', [
-            'title' => trans('entities.recently_updated_pages'),
-            'pages' => $pages
+        $this->setPageTitle(trans('entities.recently_updated_pages'));
+
+        return view('common.detailed-listing-paginated', [
+            'title'         => trans('entities.recently_updated_pages'),
+            'entities'      => $pages,
+            'showUpdatedBy' => true,
+            'showPath'      => true,
         ]);
     }
 
     /**
      * Show the view to choose a new parent to move a page into.
+     *
      * @throws NotFoundException
      */
     public function showMove(string $bookSlug, string $pageSlug)
@@ -355,14 +394,16 @@ class PageController extends Controller
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-update', $page);
         $this->checkOwnablePermission('page-delete', $page);
+
         return view('pages.move', [
             'book' => $page->book,
-            'page' => $page
+            'page' => $page,
         ]);
     }
 
     /**
      * Does the action of moving the location of a page.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -379,21 +420,22 @@ class PageController extends Controller
 
         try {
             $parent = $this->pageRepo->move($page, $entitySelection);
+        } catch (PermissionsException $exception) {
+            $this->showPermissionError();
         } catch (Exception $exception) {
-            if ($exception instanceof  PermissionsException) {
-                $this->showPermissionError();
-            }
-
             $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
+
             return redirect()->back();
         }
 
         $this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name]));
+
         return redirect($page->getUrl());
     }
 
     /**
      * Show the view to copy a page.
+     *
      * @throws NotFoundException
      */
     public function showCopy(string $bookSlug, string $pageSlug)
@@ -401,56 +443,60 @@ class PageController extends Controller
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-view', $page);
         session()->flashInput(['name' => $page->name]);
+
         return view('pages.copy', [
             'book' => $page->book,
-            'page' => $page
+            'page' => $page,
         ]);
     }
 
-
     /**
      * Create a copy of a page within the requested target destination.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
-    public function copy(Request $request, string $bookSlug, string $pageSlug)
+    public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-view', $page);
 
-        $entitySelection = $request->get('entity_selection', null) ?? null;
-        $newName = $request->get('name', null);
-
-        try {
-            $pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
-        } catch (Exception $exception) {
-            if ($exception instanceof  PermissionsException) {
-                $this->showPermissionError();
-            }
+        $entitySelection = $request->get('entity_selection') ?: null;
+        $newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
 
+        if (is_null($newParent)) {
             $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
+
             return redirect()->back();
         }
 
+        $this->checkOwnablePermission('page-create', $newParent);
+
+        $newName = $request->get('name') ?: $page->name;
+        $pageCopy = $cloner->clonePage($page, $newParent, $newName);
         $this->showSuccessNotification(trans('entities.pages_copy_success'));
+
         return redirect($pageCopy->getUrl());
     }
 
     /**
      * Show the Permissions view.
+     *
      * @throws NotFoundException
      */
     public function showPermissions(string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('restrictions-manage', $page);
+
         return view('pages.permissions', [
-            'page'  => $page,
+            'page' => $page,
         ]);
     }
 
     /**
      * Set the permissions for this page.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -462,6 +508,7 @@ class PageController extends Controller
         $permissionsUpdater->updateFromPermissionsForm($page, $request);
 
         $this->showSuccessNotification(trans('entities.pages_permissions_success'));
+
         return redirect($page->getUrl());
     }
 }
index e5e027fe72cd2f5cec19418d9ea81901238e2eb7..0287916de28f40008eeb2d16e948d6274eb77a3a 100644 (file)
@@ -2,15 +2,14 @@
 
 namespace BookStack\Http\Controllers;
 
+use BookStack\Entities\Repos\PageRepo;
 use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Entities\Tools\PageContent;
-use BookStack\Entities\Repos\PageRepo;
 use BookStack\Exceptions\NotFoundException;
 use Throwable;
 
 class PageExportController extends Controller
 {
-
     protected $pageRepo;
     protected $exportFormatter;
 
@@ -21,11 +20,13 @@ class PageExportController extends Controller
     {
         $this->pageRepo = $pageRepo;
         $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Exports a page to a PDF.
-     * https://github.com/barryvdh/laravel-dompdf
+     * https://github.com/barryvdh/laravel-dompdf.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -34,11 +35,13 @@ class PageExportController extends Controller
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $page->html = (new PageContent($page))->render();
         $pdfContent = $this->exportFormatter->pageToPdf($page);
+
         return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
     }
 
     /**
      * Export a page to a self-contained HTML file.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -47,17 +50,33 @@ class PageExportController extends Controller
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $page->html = (new PageContent($page))->render();
         $containedHtml = $this->exportFormatter->pageToContainedHtml($page);
+
         return $this->downloadResponse($containedHtml, $pageSlug . '.html');
     }
 
     /**
      * Export a page to a simple plaintext .txt file.
+     *
      * @throws NotFoundException
      */
     public function plainText(string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $pageText = $this->exportFormatter->pageToPlainText($page);
+
         return $this->downloadResponse($pageText, $pageSlug . '.txt');
     }
+
+    /**
+     * Export a page to a simple markdown .md file.
+     *
+     * @throws NotFoundException
+     */
+    public function markdown(string $bookSlug, string $pageSlug)
+    {
+        $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $pageText = $this->exportFormatter->pageToMarkdown($page);
+
+        return $this->downloadResponse($pageText, $pageSlug . '.md');
+    }
 }
index 4c43330164b743133490dbdf8e764cdfc2836383..d595a6e26fd797cb0c7693f473e0680a168caf3e 100644 (file)
@@ -1,13 +1,14 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Exceptions\NotFoundException;
 use Ssddanbrown\HtmlDiff\Diff;
 
 class PageRevisionController extends Controller
 {
-
     protected $pageRepo;
 
     /**
@@ -20,20 +21,23 @@ class PageRevisionController extends Controller
 
     /**
      * Shows the last revisions for this page.
+     *
      * @throws NotFoundException
      */
     public function index(string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
+
         return view('pages.revisions', [
-            'page' => $page,
-            'current' => $page
+            'page'    => $page,
+            'current' => $page,
         ]);
     }
 
     /**
      * Shows a preview of a single revision.
+     *
      * @throws NotFoundException
      */
     public function show(string $bookSlug, string $pageSlug, int $revisionId)
@@ -50,16 +54,18 @@ class PageRevisionController extends Controller
         $page->html = (new PageContent($page))->render();
 
         $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
+
         return view('pages.revision', [
-            'page' => $page,
-            'book' => $page->book,
-            'diff' => null,
-            'revision' => $revision
+            'page'     => $page,
+            'book'     => $page->book,
+            'diff'     => null,
+            'revision' => $revision,
         ]);
     }
 
     /**
      * Shows the changes of a single revision.
+     *
      * @throws NotFoundException
      */
     public function changes(string $bookSlug, string $pageSlug, int $revisionId)
@@ -81,15 +87,16 @@ class PageRevisionController extends Controller
         $this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
 
         return view('pages.revision', [
-            'page' => $page,
-            'book' => $page->book,
-            'diff' => $diff,
-            'revision' => $revision
+            'page'     => $page,
+            'book'     => $page->book,
+            'diff'     => $diff,
+            'revision' => $revision,
         ]);
     }
 
     /**
      * Restores a page using the content of the specified revision.
+     *
      * @throws NotFoundException
      */
     public function restore(string $bookSlug, string $pageSlug, int $revisionId)
@@ -104,6 +111,7 @@ class PageRevisionController extends Controller
 
     /**
      * Deletes a revision using the id of the specified revision.
+     *
      * @throws NotFoundException
      */
     public function destroy(string $bookSlug, string $pageSlug, int $revId)
@@ -122,11 +130,13 @@ class PageRevisionController extends Controller
         // Check if its the latest revision, cannot delete latest revision.
         if (intval($currentRevision->id) === intval($revId)) {
             $this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
+
             return redirect($page->getUrl('/revisions'));
         }
 
         $revision->delete();
         $this->showSuccessNotification(trans('entities.revision_delete_success'));
+
         return redirect($page->getUrl('/revisions'));
     }
 }
index 2307bc0d52c09b5bd6ee1411f795ac83c11f8e0a..1e24c29eeffd40c27fa63ffbe6168fb27f7ade49 100644 (file)
@@ -11,7 +11,7 @@ class PageTemplateController extends Controller
     protected $pageRepo;
 
     /**
-     * PageTemplateController constructor
+     * PageTemplateController constructor.
      */
     public function __construct(PageRepo $pageRepo)
     {
@@ -31,13 +31,14 @@ class PageTemplateController extends Controller
             $templates->appends(['search' => $search]);
         }
 
-        return view('pages.template-manager-list', [
-            'templates' => $templates
+        return view('pages.parts.template-manager-list', [
+            'templates' => $templates,
         ]);
     }
 
     /**
      * Get the content of a template.
+     *
      * @throws NotFoundException
      */
     public function get(int $templateId)
@@ -49,7 +50,7 @@ class PageTemplateController extends Controller
         }
 
         return response()->json([
-            'html' => $page->html,
+            'html'     => $page->html,
             'markdown' => $page->markdown,
         ]);
     }
index a644a2889ca28f1b0b2d648798050a27963696ae..1cffb161cc8a3f94e68d8d6eaeb72ab2e10d7730 100644 (file)
@@ -1,12 +1,14 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Tools\TrashCan;
 
 class RecycleBinController extends Controller
 {
-
     protected $recycleBinBaseUrl = '/settings/recycle-bin';
 
     /**
@@ -18,11 +20,11 @@ class RecycleBinController extends Controller
         $this->middleware(function ($request, $next) {
             $this->checkPermission('settings-manage');
             $this->checkPermission('restrictions-manage-all');
+
             return $next($request);
         });
     }
 
-
     /**
      * Show the top-level listing for the recycle bin.
      */
@@ -31,6 +33,7 @@ class RecycleBinController extends Controller
         $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
 
         $this->setPageTitle(trans('settings.recycle_bin'));
+
         return view('settings.recycle-bin.index', [
             'deletions' => $deletions,
         ]);
@@ -44,13 +47,30 @@ class RecycleBinController extends Controller
         /** @var Deletion $deletion */
         $deletion = Deletion::query()->findOrFail($id);
 
+        // Walk the parent chain to find any cascading parent deletions
+        $currentDeletable = $deletion->deletable;
+        $searching = true;
+        while ($searching && $currentDeletable instanceof Entity) {
+            $parent = $currentDeletable->getParent();
+            if ($parent && $parent->trashed()) {
+                $currentDeletable = $parent;
+            } else {
+                $searching = false;
+            }
+        }
+
+        /** @var ?Deletion $parentDeletion */
+        $parentDeletion = ($currentDeletable === $deletion->deletable) ? null : $currentDeletable->deletions()->first();
+
         return view('settings.recycle-bin.restore', [
-            'deletion' => $deletion,
+            'deletion'       => $deletion,
+            'parentDeletion' => $parentDeletion,
         ]);
     }
 
     /**
      * Restore the element attached to the given deletion.
+     *
      * @throws \Exception
      */
     public function restore(string $id)
@@ -61,6 +81,7 @@ class RecycleBinController extends Controller
         $restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
 
         $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
+
         return redirect($this->recycleBinBaseUrl);
     }
 
@@ -79,6 +100,7 @@ class RecycleBinController extends Controller
 
     /**
      * Permanently delete the content associated with the given deletion.
+     *
      * @throws \Exception
      */
     public function destroy(string $id)
@@ -89,11 +111,13 @@ class RecycleBinController extends Controller
         $deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
 
         $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+
         return redirect($this->recycleBinBaseUrl);
     }
 
     /**
      * Empty out the recycle bin.
+     *
      * @throws \Exception
      */
     public function empty()
@@ -102,6 +126,7 @@ class RecycleBinController extends Controller
 
         $this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
         $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+
         return redirect($this->recycleBinBaseUrl);
     }
 }
index e16a724a48c49fa419506a997ace27310af607b1..fee31ffbfe297a8806a9b9d12a8c310c3325418a 100644 (file)
@@ -1,6 +1,9 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\Permissions\PermissionsRepo;
+use BookStack\Auth\Role;
 use BookStack\Exceptions\PermissionsException;
 use Exception;
 use Illuminate\Http\Request;
@@ -8,7 +11,6 @@ use Illuminate\Validation\ValidationException;
 
 class RoleController extends Controller
 {
-
     protected $permissionsRepo;
 
     /**
@@ -22,20 +24,36 @@ class RoleController extends Controller
     /**
      * Show a listing of the roles in the system.
      */
-    public function list()
+    public function index()
     {
         $this->checkPermission('user-roles-manage');
         $roles = $this->permissionsRepo->getAllRoles();
+
+        $this->setPageTitle(trans('settings.roles'));
+
         return view('settings.roles.index', ['roles' => $roles]);
     }
 
     /**
-     * Show the form to create a new role
+     * Show the form to create a new role.
      */
-    public function create()
+    public function create(Request $request)
     {
         $this->checkPermission('user-roles-manage');
-        return view('settings.roles.create');
+
+        /** @var ?Role $role */
+        $role = null;
+        if ($request->has('copy_from')) {
+            $role = Role::query()->find($request->get('copy_from'));
+        }
+
+        if ($role) {
+            $role->display_name .= ' (' . trans('common.copy') . ')';
+        }
+
+        $this->setPageTitle(trans('settings.role_create'));
+
+        return view('settings.roles.create', ['role' => $role]);
     }
 
     /**
@@ -45,17 +63,19 @@ class RoleController extends Controller
     {
         $this->checkPermission('user-roles-manage');
         $this->validate($request, [
-            'display_name' => 'required|min:3|max:180',
-            'description' => 'max:180'
+            'display_name' => ['required', 'min:3', 'max:180'],
+            'description'  => ['max:180'],
         ]);
 
         $this->permissionsRepo->saveNewRole($request->all());
         $this->showSuccessNotification(trans('settings.role_create_success'));
+
         return redirect('/settings/roles');
     }
 
     /**
      * Show the form for editing a user role.
+     *
      * @throws PermissionsException
      */
     public function edit(string $id)
@@ -65,23 +85,28 @@ class RoleController extends Controller
         if ($role->hidden) {
             throw new PermissionsException(trans('errors.role_cannot_be_edited'));
         }
+
+        $this->setPageTitle(trans('settings.role_edit'));
+
         return view('settings.roles.edit', ['role' => $role]);
     }
 
     /**
      * Updates a user role.
+     *
      * @throws ValidationException
      */
     public function update(Request $request, string $id)
     {
         $this->checkPermission('user-roles-manage');
         $this->validate($request, [
-            'display_name' => 'required|min:3|max:180',
-            'description' => 'max:180'
+            'display_name' => ['required', 'min:3', 'max:180'],
+            'description'  => ['max:180'],
         ]);
 
         $this->permissionsRepo->updateRole($id, $request->all());
         $this->showSuccessNotification(trans('settings.role_update_success'));
+
         return redirect('/settings/roles');
     }
 
@@ -96,12 +121,16 @@ class RoleController extends Controller
         $roles = $this->permissionsRepo->getAllRolesExcept($role);
         $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
         $roles->prepend($blankRole);
+
+        $this->setPageTitle(trans('settings.role_delete'));
+
         return view('settings.roles.delete', ['role' => $role, 'roles' => $roles]);
     }
 
     /**
      * Delete a role from the system,
      * Migrate from a previous role if set.
+     *
      * @throws Exception
      */
     public function delete(Request $request, string $id)
@@ -112,10 +141,12 @@ class RoleController extends Controller
             $this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id'));
         } catch (PermissionsException $e) {
             $this->showErrorNotification($e->getMessage());
+
             return redirect()->back();
         }
 
         $this->showSuccessNotification(trans('settings.role_delete_success'));
+
         return redirect('/settings/roles');
     }
 }
index bb824fd9bfedf1dec20799df7ab25d0b536774e3..6b2be5a2d77515433a2fbb4d78deedebb8bf1bda 100644 (file)
@@ -1,49 +1,46 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
-use BookStack\Actions\ViewService;
-use BookStack\Entities\Tools\SearchRunner;
-use BookStack\Entities\Tools\ShelfContext;
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Queries\Popular;
 use BookStack\Entities\Tools\SearchOptions;
+use BookStack\Entities\Tools\SearchResultsFormatter;
+use BookStack\Entities\Tools\SearchRunner;
 use BookStack\Entities\Tools\SiblingFetcher;
 use Illuminate\Http\Request;
 
 class SearchController extends Controller
 {
-    protected $viewService;
     protected $searchRunner;
     protected $entityContextManager;
 
-    public function __construct(
-        ViewService $viewService,
-        SearchRunner $searchRunner,
-        ShelfContext $entityContextManager
-    ) {
-        $this->viewService = $viewService;
+    public function __construct(SearchRunner $searchRunner)
+    {
         $this->searchRunner = $searchRunner;
-        $this->entityContextManager = $entityContextManager;
     }
 
     /**
      * Searches all entities.
      */
-    public function search(Request $request)
+    public function search(Request $request, SearchResultsFormatter $formatter)
     {
         $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));
+        $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
 
         $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
+        $formatter->format($results['results']->all(), $searchOpts);
 
         return view('search.all', [
-            'entities'   => $results['results'],
+            'entities'     => $results['results'],
             'totalResults' => $results['total'],
-            'searchTerm' => $fullSearchString,
-            'hasNextPage' => $results['has_more'],
+            'searchTerm'   => $fullSearchString,
+            'hasNextPage'  => $results['has_more'],
             'nextPageLink' => $nextPageLink,
-            'options' => $searchOpts,
+            'options'      => $searchOpts,
         ]);
     }
 
@@ -54,7 +51,8 @@ class SearchController extends Controller
     {
         $term = $request->get('term', '');
         $results = $this->searchRunner->searchBook($bookId, $term);
-        return view('partials.entity-list', ['entities' => $results]);
+
+        return view('entities.list', ['entities' => $results]);
     }
 
     /**
@@ -64,7 +62,8 @@ class SearchController extends Controller
     {
         $term = $request->get('term', '');
         $results = $this->searchRunner->searchChapter($chapterId, $term);
-        return view('partials.entity-list', ['entities' => $results]);
+
+        return view('entities.list', ['entities' => $results]);
     }
 
     /**
@@ -74,18 +73,18 @@ class SearchController extends Controller
     public function searchEntitiesAjax(Request $request)
     {
         $entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
-        $searchTerm =  $request->get('term', false);
+        $searchTerm = $request->get('term', false);
         $permission = $request->get('permission', 'view');
 
         // Search for entities otherwise show most popular
         if ($searchTerm !== false) {
-            $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
+            $searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
             $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
         } else {
-            $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
+            $entities = (new Popular())->run(20, 0, $entityTypes, $permission);
         }
 
-        return view('search.entity-ajax-list', ['entities' => $entities]);
+        return view('search.parts.entity-ajax-list', ['entities' => $entities]);
     }
 
     /**
@@ -96,7 +95,8 @@ class SearchController extends Controller
         $type = $request->get('entity_type', null);
         $id = $request->get('entity_id', null);
 
-        $entities = (new SiblingFetcher)->fetch($type, $id);
-        return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']);
+        $entities = (new SiblingFetcher())->fetch($type, $id);
+
+        return view('entities.list-basic', ['entities' => $entities, 'style' => 'compact']);
     }
 }
index f02f541bc9ba5b1a9ad14270d900ae053b23900d..b12b0e3cece02cd0dc80627f1a064e498dab8d33 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
@@ -29,8 +31,8 @@ class SettingController extends Controller
         $version = trim(file_get_contents(base_path('version')));
 
         return view('settings.index', [
-            'version' => $version,
-            'guestUser' => User::getDefault()
+            'version'   => $version,
+            'guestUser' => User::getDefault(),
         ]);
     }
 
@@ -42,7 +44,7 @@ class SettingController extends Controller
         $this->preventAccessInDemoMode();
         $this->checkPermission('settings-manage');
         $this->validate($request, [
-            'app_logo' => 'nullable|' . $this->getImageValidationRules(),
+            'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
         // Cycles through posted settings and update them
@@ -72,6 +74,7 @@ class SettingController extends Controller
         $this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
         $this->showSuccessNotification(trans('settings.settings_save_success'));
         $redirectLocation = '/settings#' . $section;
+
         return redirect(rtrim($redirectLocation, '#'));
     }
 }
index 9f4ed4d893712affeeaaca24b9301b3523cbaa48..ab4f67d0a68628b9e60814f2572804d6a34c930a 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
@@ -7,7 +9,6 @@ use Illuminate\Support\Str;
 
 class StatusController extends Controller
 {
-
     /**
      * Show the system status as a simple json page.
      */
@@ -19,17 +20,20 @@ class StatusController extends Controller
             }),
             'cache' => $this->trueWithoutError(function () {
                 $rand = Str::random();
-                Cache::set('status_test', $rand);
-                return Cache::get('status_test') === $rand;
+                Cache::add('status_test', $rand);
+
+                return Cache::pull('status_test') === $rand;
             }),
             'session' => $this->trueWithoutError(function () {
                 $rand = Str::random();
                 Session::put('status_test', $rand);
+
                 return Session::get('status_test') === $rand;
             }),
         ];
 
         $hasError = in_array(false, $statuses);
+
         return response()->json($statuses, $hasError ? 500 : 200);
     }
 
index ce84bf4101e4c23f6437915a35c3d014a7c306a5..e59580b6065a2a816f3a2b6fade8526e62d80ef4 100644 (file)
@@ -1,11 +1,12 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\TagRepo;
 use Illuminate\Http\Request;
 
 class TagController extends Controller
 {
-
     protected $tagRepo;
 
     /**
@@ -16,6 +17,30 @@ class TagController extends Controller
         $this->tagRepo = $tagRepo;
     }
 
+    /**
+     * Show a listing of existing tags in the system.
+     */
+    public function index(Request $request)
+    {
+        $search = $request->get('search', '');
+        $nameFilter = $request->get('name', '');
+        $tags = $this->tagRepo
+            ->queryWithTotals($search, $nameFilter)
+            ->paginate(50)
+            ->appends(array_filter([
+                'search' => $search,
+                'name'   => $nameFilter,
+            ]));
+
+        $this->setPageTitle(trans('entities.tags'));
+
+        return view('tags.index', [
+            'tags'       => $tags,
+            'search'     => $search,
+            'nameFilter' => $nameFilter,
+        ]);
+    }
+
     /**
      * Get tag name suggestions from a given search term.
      */
@@ -23,6 +48,7 @@ class TagController extends Controller
     {
         $searchTerm = $request->get('search', null);
         $suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
+
         return response()->json($suggestions);
     }
 
@@ -34,6 +60,7 @@ class TagController extends Controller
         $searchTerm = $request->get('search', null);
         $tagName = $request->get('name', null);
         $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
+
         return response()->json($suggestions);
     }
 }
index 3949722ead098b1d7c7e0b82dc8ca02a27a73eb3..b1a043cd3c9b27bf26df2655693907fa0e96f046 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Api\ApiToken;
@@ -9,7 +11,6 @@ use Illuminate\Support\Str;
 
 class UserApiTokenController extends Controller
 {
-
     /**
      * Show the form to create a new API token.
      */
@@ -20,6 +21,7 @@ class UserApiTokenController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $userId);
 
         $user = User::query()->findOrFail($userId);
+
         return view('users.api-tokens.create', [
             'user' => $user,
         ]);
@@ -34,18 +36,18 @@ class UserApiTokenController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $userId);
 
         $this->validate($request, [
-            'name' => 'required|max:250',
-            'expires_at' => 'date_format:Y-m-d',
+            'name'       => ['required', 'max:250'],
+            'expires_at' => ['date_format:Y-m-d'],
         ]);
 
         $user = User::query()->findOrFail($userId);
         $secret = Str::random(32);
 
         $token = (new ApiToken())->forceFill([
-            'name' => $request->get('name'),
-            'token_id' => Str::random(32),
-            'secret' => Hash::make($secret),
-            'user_id' => $user->id,
+            'name'       => $request->get('name'),
+            'token_id'   => Str::random(32),
+            'secret'     => Hash::make($secret),
+            'user_id'    => $user->id,
             'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
         ]);
 
@@ -71,9 +73,9 @@ class UserApiTokenController extends Controller
         $secret = session()->pull('api-token-secret:' . $token->id, null);
 
         return view('users.api-tokens.edit', [
-            'user' => $user,
-            'token' => $token,
-            'model' => $token,
+            'user'   => $user,
+            'token'  => $token,
+            'model'  => $token,
             'secret' => $secret,
         ]);
     }
@@ -84,18 +86,19 @@ class UserApiTokenController extends Controller
     public function update(Request $request, int $userId, int $tokenId)
     {
         $this->validate($request, [
-            'name' => 'required|max:250',
-            'expires_at' => 'date_format:Y-m-d',
+            'name'       => ['required', 'max:250'],
+            'expires_at' => ['date_format:Y-m-d'],
         ]);
 
         [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
         $token->fill([
-            'name' => $request->get('name'),
+            'name'       => $request->get('name'),
             'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
         ])->save();
 
         $this->showSuccessNotification(trans('settings.user_api_token_update_success'));
         $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
+
         return redirect($user->getEditUrl('/api-tokens/' . $token->id));
     }
 
@@ -105,8 +108,9 @@ class UserApiTokenController extends Controller
     public function delete(int $userId, int $tokenId)
     {
         [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
+
         return view('users.api-tokens.delete', [
-            'user' => $user,
+            'user'  => $user,
             'token' => $token,
         ]);
     }
@@ -138,6 +142,7 @@ class UserApiTokenController extends Controller
 
         $user = User::query()->findOrFail($userId);
         $token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail();
+
         return [$user, $token];
     }
 }
index ba35904378a6f2ca04a76baf26f78cc94b56388a..3903682eb41a2f11179a89b6abcdff9bcfc572a5 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\SocialAuthService;
@@ -10,12 +12,13 @@ use BookStack\Exceptions\UserUpdateException;
 use BookStack\Uploads\ImageRepo;
 use Exception;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
+use Illuminate\Validation\Rules\Password;
 use Illuminate\Validation\ValidationException;
 
 class UserController extends Controller
 {
-
     protected $user;
     protected $userRepo;
     protected $inviteService;
@@ -39,14 +42,15 @@ class UserController extends Controller
     {
         $this->checkPermission('users-manage');
         $listDetails = [
-            'order' => $request->get('order', 'asc'),
+            'order'  => $request->get('order', 'asc'),
             'search' => $request->get('search', ''),
-            'sort' => $request->get('sort', 'name'),
+            'sort'   => $request->get('sort', 'name'),
         ];
         $users = $this->userRepo->getAllUsersPaginatedAndSorted(20, $listDetails);
 
         $this->setPageTitle(trans('settings.users'));
         $users->appends($listDetails);
+
         return view('users.index', ['users' => $users, 'listDetails' => $listDetails]);
     }
 
@@ -58,11 +62,14 @@ class UserController extends Controller
         $this->checkPermission('users-manage');
         $authMethod = config('auth.method');
         $roles = $this->userRepo->getAllRoles();
+        $this->setPageTitle(trans('settings.users_add_new'));
+
         return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
     }
 
     /**
      * Store a newly created user in storage.
+     *
      * @throws UserUpdateException
      * @throws ValidationException
      */
@@ -70,18 +77,19 @@ class UserController extends Controller
     {
         $this->checkPermission('users-manage');
         $validationRules = [
-            'name'  => 'required',
-            'email' => 'required|email|unique:users,email'
+            'name'    => ['required'],
+            'email'   => ['required', 'email', 'unique:users,email'],
+            'setting' => ['array'],
         ];
 
         $authMethod = config('auth.method');
         $sendInvite = ($request->get('send_invite', 'false') === 'true');
 
         if ($authMethod === 'standard' && !$sendInvite) {
-            $validationRules['password'] = 'required|min:6';
-            $validationRules['password-confirm'] = 'required|same:password';
-        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') {
-            $validationRules['external_auth_id'] = 'required';
+            $validationRules['password'] = ['required', Password::default()];
+            $validationRules['password-confirm'] = ['required', 'same:password'];
+        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
+            $validationRules['external_auth_id'] = ['required'];
         }
         $this->validate($request, $validationRules);
 
@@ -89,25 +97,36 @@ class UserController extends Controller
 
         if ($authMethod === 'standard') {
             $user->password = bcrypt($request->get('password', Str::random(32)));
-        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2') {
+        } elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
             $user->external_auth_id = $request->get('external_auth_id');
         }
 
         $user->refreshSlug();
-        $user->save();
 
-        if ($sendInvite) {
-            $this->inviteService->sendInvitation($user);
-        }
+        DB::transaction(function () use ($user, $sendInvite, $request) {
+            $user->save();
 
-        if ($request->filled('roles')) {
-            $roles = $request->get('roles');
-            $this->userRepo->setUserRoles($user, $roles);
-        }
+            // Save user-specific settings
+            if ($request->filled('setting')) {
+                foreach ($request->get('setting') as $key => $value) {
+                    setting()->putUser($user, $key, $value);
+                }
+            }
 
-        $this->userRepo->downloadAndAssignUserAvatar($user);
+            if ($sendInvite) {
+                $this->inviteService->sendInvitation($user);
+            }
+
+            if ($request->filled('roles')) {
+                $roles = $request->get('roles');
+                $this->userRepo->setUserRoles($user, $roles);
+            }
+
+            $this->userRepo->downloadAndAssignUserAvatar($user);
+
+            $this->logActivity(ActivityType::USER_CREATE, $user);
+        });
 
-        $this->logActivity(ActivityType::USER_CREATE, $user);
         return redirect('/settings/users');
     }
 
@@ -118,23 +137,28 @@ class UserController extends Controller
     {
         $this->checkPermissionOrCurrentUser('users-manage', $id);
 
-        $user = $this->user->newQuery()->with(['apiTokens'])->findOrFail($id);
+        /** @var User $user */
+        $user = $this->user->newQuery()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
 
         $authMethod = ($user->system_name) ? 'system' : config('auth.method');
 
         $activeSocialDrivers = $socialAuthService->getActiveDrivers();
+        $mfaMethods = $user->mfaValues->groupBy('method');
         $this->setPageTitle(trans('settings.user_profile'));
         $roles = $this->userRepo->getAllRoles();
+
         return view('users.edit', [
-            'user' => $user,
+            'user'                => $user,
             'activeSocialDrivers' => $activeSocialDrivers,
-            'authMethod' => $authMethod,
-            'roles' => $roles
+            'mfaMethods'          => $mfaMethods,
+            'authMethod'          => $authMethod,
+            'roles'               => $roles,
         ]);
     }
 
     /**
      * Update the specified user in storage.
+     *
      * @throws UserUpdateException
      * @throws ImageUploadException
      * @throws ValidationException
@@ -145,12 +169,12 @@ class UserController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $id);
 
         $this->validate($request, [
-            'name'             => 'min:2',
-            'email'            => 'min:2|email|unique:users,email,' . $id,
-            'password'         => 'min:6|required_with:password_confirm',
-            'password-confirm' => 'same:password|required_with:password',
-            'setting'          => 'array',
-            'profile_image'    => 'nullable|' . $this->getImageValidationRules(),
+            'name'             => ['min:2'],
+            'email'            => ['min:2', 'email', 'unique:users,email,' . $id],
+            'password'         => ['required_with:password_confirm', Password::default()],
+            'password-confirm' => ['same:password', 'required_with:password'],
+            'setting'          => ['array'],
+            'profile_image'    => array_merge(['nullable'], $this->getImageValidationRules()),
         ]);
 
         $user = $this->userRepo->getById($id);
@@ -183,7 +207,7 @@ class UserController extends Controller
             $user->external_auth_id = $request->get('external_auth_id');
         }
 
-        // Save an user-specific settings
+        // Save user-specific settings
         if ($request->filled('setting')) {
             foreach ($request->get('setting') as $key => $value) {
                 setting()->putUser($user, $key, $value);
@@ -208,6 +232,7 @@ class UserController extends Controller
         $this->logActivity(ActivityType::USER_UPDATE, $user);
 
         $redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
+
         return redirect($redirectUrl);
     }
 
@@ -220,11 +245,13 @@ class UserController extends Controller
 
         $user = $this->userRepo->getById($id);
         $this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name]));
+
         return view('users.delete', ['user' => $user]);
     }
 
     /**
      * Remove the specified user from storage.
+     *
      * @throws Exception
      */
     public function destroy(Request $request, int $id)
@@ -237,11 +264,13 @@ class UserController extends Controller
 
         if ($this->userRepo->isOnlyAdmin($user)) {
             $this->showErrorNotification(trans('errors.users_cannot_delete_only_admin'));
+
             return redirect($user->getEditUrl());
         }
 
         if ($user->system_name === 'public') {
             $this->showErrorNotification(trans('errors.users_cannot_delete_guest'));
+
             return redirect($user->getEditUrl());
         }
 
@@ -304,6 +333,7 @@ class UserController extends Controller
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
+
         return $this->changeListSort($id, $request, $type);
     }
 
@@ -314,6 +344,7 @@ class UserController extends Controller
     {
         $enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
         setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
+
         return redirect()->back();
     }
 
@@ -325,14 +356,15 @@ class UserController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $id);
         $keyWhitelist = ['home-details'];
         if (!in_array($key, $keyWhitelist)) {
-            return response("Invalid key", 500);
+            return response('Invalid key', 500);
         }
 
         $newState = $request->get('expand', 'false');
 
         $user = $this->user->findOrFail($id);
         setting()->putUser($user, 'section_expansion#' . $key, $newState);
-        return response("", 204);
+
+        return response('', 204);
     }
 
     /**
index 95e68cb07d95e97a583be7a37def344bcadc8819..5fd8f7b888963d9c7e087fcf94e7f4fc18e10435 100644 (file)
@@ -1,25 +1,30 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityQueries;
 use BookStack\Auth\UserRepo;
 
 class UserProfileController extends Controller
 {
     /**
-     * Show the user profile page
+     * Show the user profile page.
      */
-    public function show(UserRepo $repo, string $slug)
+    public function show(UserRepo $repo, ActivityQueries $activities, string $slug)
     {
         $user = $repo->getBySlug($slug);
 
-        $userActivity = $repo->getActivity($user);
+        $userActivity = $activities->userActivity($user);
         $recentlyCreated = $repo->getRecentlyCreated($user, 5);
         $assetCounts = $repo->getAssetCounts($user);
 
+        $this->setPageTitle($user->name);
+
         return view('users.profile', [
-            'user' => $user,
-            'activity' => $userActivity,
+            'user'            => $user,
+            'activity'        => $userActivity,
             'recentlyCreated' => $recentlyCreated,
-            'assetCounts' => $assetCounts
+            'assetCounts'     => $assetCounts,
         ]);
     }
 }
index a0dfbd8d06696ec8dc8be83ef4ac9747730af998..df234347c5cfd2151c50df84eb49af1385fdc232 100644 (file)
@@ -3,7 +3,6 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\User;
-use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\Request;
 
 class UserSearchController extends Controller
@@ -14,18 +13,27 @@ class UserSearchController extends Controller
      */
     public function forSelect(Request $request)
     {
+        $hasPermission = signedInUser() && (
+            userCan('users-manage')
+                || userCan('restrictions-manage-own')
+                || userCan('restrictions-manage-all')
+        );
+
+        if (!$hasPermission) {
+            $this->showPermissionError();
+        }
+
         $search = $request->get('search', '');
-        $query = User::query()->orderBy('name', 'desc')
+        $query = User::query()
+            ->orderBy('name', 'asc')
             ->take(20);
 
         if (!empty($search)) {
-            $query->where(function (Builder $query) use ($search) {
-                $query->where('email', 'like', '%' . $search . '%')
-                    ->orWhere('name', 'like', '%' . $search . '%');
-            });
+            $query->where('name', 'like', '%' . $search . '%');
         }
 
-        $users = $query->get();
-        return view('components.user-select-list', compact('users'));
+        return view('form.user-select-list', [
+            'users' => $query->get(),
+        ]);
     }
 }
diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php
new file mode 100644 (file)
index 0000000..264921d
--- /dev/null
@@ -0,0 +1,134 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use Illuminate\Http\Request;
+
+class WebhookController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware([
+            'can:settings-manage',
+        ]);
+    }
+
+    /**
+     * Show all webhooks configured in the system.
+     */
+    public function index()
+    {
+        $webhooks = Webhook::query()
+            ->orderBy('name', 'desc')
+            ->with('trackedEvents')
+            ->get();
+
+        $this->setPageTitle(trans('settings.webhooks'));
+
+        return view('settings.webhooks.index', ['webhooks' => $webhooks]);
+    }
+
+    /**
+     * Show the view for creating a new webhook in the system.
+     */
+    public function create()
+    {
+        $this->setPageTitle(trans('settings.webhooks_create'));
+
+        return view('settings.webhooks.create');
+    }
+
+    /**
+     * Store a new webhook in the system.
+     */
+    public function store(Request $request)
+    {
+        $validated = $this->validate($request, [
+            'name'     => ['required', 'max:150'],
+            'endpoint' => ['required', 'url', 'max:500'],
+            'events'   => ['required', 'array'],
+            'active'   => ['required'],
+            'timeout'  => ['required', 'integer', 'min:1', 'max:600'],
+        ]);
+
+        $webhook = new Webhook($validated);
+        $webhook->active = $validated['active'] === 'true';
+        $webhook->save();
+        $webhook->updateTrackedEvents(array_values($validated['events']));
+
+        $this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook);
+
+        return redirect('/settings/webhooks');
+    }
+
+    /**
+     * Show the view to edit an existing webhook.
+     */
+    public function edit(string $id)
+    {
+        /** @var Webhook $webhook */
+        $webhook = Webhook::query()
+            ->with('trackedEvents')
+            ->findOrFail($id);
+
+        $this->setPageTitle(trans('settings.webhooks_edit'));
+
+        return view('settings.webhooks.edit', ['webhook' => $webhook]);
+    }
+
+    /**
+     * Update an existing webhook with the provided request data.
+     */
+    public function update(Request $request, string $id)
+    {
+        $validated = $this->validate($request, [
+            'name'     => ['required', 'max:150'],
+            'endpoint' => ['required', 'url', 'max:500'],
+            'events'   => ['required', 'array'],
+            'active'   => ['required'],
+            'timeout'  => ['required', 'integer', 'min:1', 'max:600'],
+        ]);
+
+        /** @var Webhook $webhook */
+        $webhook = Webhook::query()->findOrFail($id);
+
+        $webhook->active = $validated['active'] === 'true';
+        $webhook->fill($validated)->save();
+        $webhook->updateTrackedEvents($validated['events']);
+
+        $this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook);
+
+        return redirect('/settings/webhooks');
+    }
+
+    /**
+     * Show the view to delete a webhook.
+     */
+    public function delete(string $id)
+    {
+        /** @var Webhook $webhook */
+        $webhook = Webhook::query()->findOrFail($id);
+
+        $this->setPageTitle(trans('settings.webhooks_delete'));
+
+        return view('settings.webhooks.delete', ['webhook' => $webhook]);
+    }
+
+    /**
+     * Destroy a webhook from the system.
+     */
+    public function destroy(string $id)
+    {
+        /** @var Webhook $webhook */
+        $webhook = Webhook::query()->findOrFail($id);
+
+        $webhook->trackedEvents()->delete();
+        $webhook->delete();
+
+        $this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook);
+
+        return redirect('/settings/webhooks');
+    }
+}
index 694036ab48c87ecb2cfe62417e95eef23b15d563..91dbdd9634c2bfa02624a69b2f0e188726ef83f8 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http;
+<?php
+
+namespace BookStack\Http;
 
 use Illuminate\Foundation\Http\Kernel as HttpKernel;
 
@@ -9,7 +11,7 @@ class Kernel extends HttpKernel
      * These middleware are run during every request to your application.
      */
     protected $middleware = [
-        \BookStack\Http\Middleware\CheckForMaintenanceMode::class,
+        \BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class,
         \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
         \BookStack\Http\Middleware\TrimStrings::class,
         \BookStack\Http\Middleware\TrustProxies::class,
@@ -22,12 +24,14 @@ class Kernel extends HttpKernel
      */
     protected $middlewareGroups = [
         'web' => [
-            \BookStack\Http\Middleware\ControlIframeSecurity::class,
+            \BookStack\Http\Middleware\ApplyCspRules::class,
             \BookStack\Http\Middleware\EncryptCookies::class,
             \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
             \Illuminate\Session\Middleware\StartSession::class,
             \Illuminate\View\Middleware\ShareErrorsFromSession::class,
             \BookStack\Http\Middleware\VerifyCsrfToken::class,
+            \BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
+            \BookStack\Http\Middleware\CheckEmailConfirmed::class,
             \BookStack\Http\Middleware\RunThemeActions::class,
             \BookStack\Http\Middleware\Localization::class,
         ],
@@ -36,6 +40,8 @@ class Kernel extends HttpKernel
             \BookStack\Http\Middleware\EncryptCookies::class,
             \BookStack\Http\Middleware\StartSessionIfCookieExists::class,
             \BookStack\Http\Middleware\ApiAuthenticate::class,
+            \BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
+            \BookStack\Http\Middleware\CheckEmailConfirmed::class,
         ],
     ];
 
@@ -46,10 +52,10 @@ class Kernel extends HttpKernel
      */
     protected $routeMiddleware = [
         'auth'       => \BookStack\Http\Middleware\Authenticate::class,
-        'can'        => \Illuminate\Auth\Middleware\Authorize::class,
+        'can'        => \BookStack\Http\Middleware\CheckUserHasPermission::class,
         'guest'      => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,
         'throttle'   => \Illuminate\Routing\Middleware\ThrottleRequests::class,
-        'perm'       => \BookStack\Http\Middleware\PermissionMiddleware::class,
         'guard'      => \BookStack\Http\Middleware\CheckGuard::class,
+        'mfa-setup'  => \BookStack\Http\Middleware\AuthenticatedOrPendingMfa::class,
     ];
 }
index 728057bed175b42a880014b0ecfbd6c3962d2701..5d621ac119a22abf16c103d4cfc40dc617fcb5e8 100644 (file)
@@ -9,8 +9,6 @@ use Illuminate\Http\Request;
 
 class ApiAuthenticate
 {
-    use ChecksForEmailConfirmation;
-
     /**
      * Handle an incoming request.
      */
@@ -29,6 +27,7 @@ class ApiAuthenticate
     /**
      * Ensure the current user can access authenticated API routes, either via existing session
      * authentication or via API Token authentication.
+     *
      * @throws UnauthorizedException
      */
     protected function ensureAuthorizedBySessionOrToken(): void
@@ -36,10 +35,10 @@ class ApiAuthenticate
         // Return if the user is already found to be signed in via session-based auth.
         // This is to make it easy to browser the API via browser after just logging into the system.
         if (signedInUser() || session()->isStarted()) {
-            $this->ensureEmailConfirmedIfRequested();
-            if (!user()->can('access-api')) {
+            if (!$this->sessionUserHasApiAccess()) {
                 throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
             }
+
             return;
         }
 
@@ -48,7 +47,16 @@ class ApiAuthenticate
 
         // Validate the token and it's users API access
         auth()->authenticate();
-        $this->ensureEmailConfirmedIfRequested();
+    }
+
+    /**
+     * Check if the active session user has API access.
+     */
+    protected function sessionUserHasApiAccess(): bool
+    {
+        $hasApiPermission = user()->can('access-api');
+
+        return $hasApiPermission && hasAppAccess();
     }
 
     /**
@@ -58,9 +66,9 @@ class ApiAuthenticate
     {
         return response()->json([
             'error' => [
-                'code' => $code,
+                'code'    => $code,
                 'message' => $message,
-            ]
+            ],
         ], $code);
     }
 }
diff --git a/app/Http/Middleware/ApplyCspRules.php b/app/Http/Middleware/ApplyCspRules.php
new file mode 100644 (file)
index 0000000..6c9d14e
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Util\CspService;
+use Closure;
+use Illuminate\Http\Request;
+
+class ApplyCspRules
+{
+    /**
+     * @var CspService
+     */
+    protected $cspService;
+
+    public function __construct(CspService $cspService)
+    {
+        $this->cspService = $cspService;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param Request $request
+     * @param Closure $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        view()->share('cspNonce', $this->cspService->getNonce());
+        if ($this->cspService->allowedIFrameHostsConfigured()) {
+            config()->set('session.same_site', 'none');
+        }
+
+        $response = $next($request);
+
+        $this->cspService->setFrameAncestors($response);
+        $this->cspService->setScriptSrc($response);
+        $this->cspService->setObjectSrc($response);
+        $this->cspService->setBaseUri($response);
+
+        return $response;
+    }
+}
index df8c44d351cc92784bc8adaec1f642ea0c1719a0..a320291122b6f632b0bc36a8e96d15e42472a5f8 100644 (file)
@@ -7,47 +7,19 @@ use Illuminate\Http\Request;
 
 class Authenticate
 {
-    use ChecksForEmailConfirmation;
-
     /**
      * Handle an incoming request.
      */
     public function handle(Request $request, Closure $next)
     {
-        if ($this->awaitingEmailConfirmation()) {
-            return $this->emailConfirmationErrorResponse($request);
-        }
-
         if (!hasAppAccess()) {
             if ($request->ajax()) {
                 return response('Unauthorized.', 401);
-            } else {
-                return redirect()->guest(url('/login'));
             }
-        }
-
-        return $next($request);
-    }
 
-    /**
-     * Provide an error response for when the current user's email is not confirmed
-     * in a system which requires it.
-     */
-    protected function emailConfirmationErrorResponse(Request $request)
-    {
-        if ($request->wantsJson()) {
-            return response()->json([
-                'error' => [
-                    'code' => 401,
-                    'message' => trans('errors.email_confirmation_awaiting')
-                ]
-            ], 401);
+            return redirect()->guest(url('/login'));
         }
 
-        if (session()->get('sent-email-confirmation') === true) {
-            return redirect('/register/confirm');
-        }
-
-        return redirect('/register/confirm/awaiting');
+        return $next($request);
     }
 }
diff --git a/app/Http/Middleware/AuthenticatedOrPendingMfa.php b/app/Http/Middleware/AuthenticatedOrPendingMfa.php
new file mode 100644 (file)
index 0000000..0a05888
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\Mfa\MfaSession;
+use Closure;
+
+class AuthenticatedOrPendingMfa
+{
+    protected $loginService;
+    protected $mfaSession;
+
+    public function __construct(LoginService $loginService, MfaSession $mfaSession)
+    {
+        $this->loginService = $loginService;
+        $this->mfaSession = $mfaSession;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        $user = auth()->user();
+        $loggedIn = $user !== null;
+        $lastAttemptUser = $this->loginService->getLastLoginAttemptUser();
+
+        if ($loggedIn || ($lastAttemptUser && $this->mfaSession->isPendingMfaSetup($lastAttemptUser))) {
+            return $next($request);
+        }
+
+        return redirect()->to(url('/login'));
+    }
+}
diff --git a/app/Http/Middleware/CheckEmailConfirmed.php b/app/Http/Middleware/CheckEmailConfirmed.php
new file mode 100644 (file)
index 0000000..7dd970a
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Auth\Access\EmailConfirmationService;
+use BookStack\Auth\User;
+use Closure;
+
+/**
+ * Check that the user's email address is confirmed.
+ *
+ * As of v21.08 this is technically not required but kept as a prevention
+ * to log out any users that may be logged in but in an "awaiting confirmation" state.
+ * We'll keep this for a while until it'd be very unlikely for a user to be upgrading from
+ * a pre-v21.08 version.
+ *
+ * Ideally we'd simply invalidate all existing sessions upon update but that has
+ * proven to be a lot more difficult than expected.
+ */
+class CheckEmailConfirmed
+{
+    protected $confirmationService;
+
+    public function __construct(EmailConfirmationService $confirmationService)
+    {
+        $this->confirmationService = $confirmationService;
+    }
+
+    /**
+     * Handle an incoming request.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        /** @var User $user */
+        $user = auth()->user();
+        if (auth()->check() && !$user->email_confirmed && $this->confirmationService->confirmationRequired()) {
+            auth()->logout();
+
+            return redirect()->to('/');
+        }
+
+        return $next($request);
+    }
+}
index cc73ea68d36cdf438e184f0cfdbd2da489bebf52..adc1d1f3ec0ab4a9b7e3044b56e021a52f0e1453 100644 (file)
@@ -9,9 +9,10 @@ class CheckGuard
     /**
      * Handle an incoming request.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \Closure  $next
-     * @param string $allowedGuards
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     * @param string                   $allowedGuards
+     *
      * @return mixed
      */
     public function handle($request, Closure $next, ...$allowedGuards)
@@ -19,6 +20,7 @@ class CheckGuard
         $activeGuard = config('auth.method');
         if (!in_array($activeGuard, $allowedGuards)) {
             session()->flash('error', trans('errors.permission'));
+
             return redirect('/');
         }
 
diff --git a/app/Http/Middleware/CheckUserHasPermission.php b/app/Http/Middleware/CheckUserHasPermission.php
new file mode 100644 (file)
index 0000000..b5678e7
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+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)
+    {
+        if (!user()->can($permission)) {
+            return $this->errorResponse($request);
+        }
+
+        return $next($request);
+    }
+
+    protected function errorResponse(Request $request)
+    {
+        if ($request->wantsJson()) {
+            return response()->json(['error' => trans('errors.permissionJson')], 403);
+        }
+
+        session()->flash('error', trans('errors.permission'));
+
+        return redirect('/');
+    }
+}
diff --git a/app/Http/Middleware/ChecksForEmailConfirmation.php b/app/Http/Middleware/ChecksForEmailConfirmation.php
deleted file mode 100644 (file)
index cbf5504..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php
-
-namespace BookStack\Http\Middleware;
-
-use BookStack\Exceptions\UnauthorizedException;
-use Illuminate\Http\Request;
-
-trait ChecksForEmailConfirmation
-{
-    /**
-     * Check if the current user has a confirmed email if the instance deems it as required.
-     * Throws if confirmation is required by the user.
-     * @throws UnauthorizedException
-     */
-    protected function ensureEmailConfirmedIfRequested()
-    {
-        if ($this->awaitingEmailConfirmation()) {
-            throw new UnauthorizedException(trans('errors.email_confirmation_awaiting'));
-        }
-    }
-
-    /**
-     * Check if email confirmation is required and the current user is awaiting confirmation.
-     */
-    protected function awaitingEmailConfirmation(): bool
-    {
-        if (auth()->check()) {
-            $requireConfirmation = (setting('registration-confirmation') || setting('registration-restrict'));
-            if ($requireConfirmation && !auth()->user()->email_confirmed) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-}
diff --git a/app/Http/Middleware/ControlIframeSecurity.php b/app/Http/Middleware/ControlIframeSecurity.php
deleted file mode 100644 (file)
index cc80344..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php
-
-namespace BookStack\Http\Middleware;
-
-use Closure;
-use Symfony\Component\HttpFoundation\Response;
-
-/**
- * Sets CSP headers to restrict the hosts that BookStack can be
- * iframed within. Also adjusts the cookie samesite options
- * so that cookies will operate in the third-party context.
- */
-class ControlIframeSecurity
-{
-    /**
-     * Handle an incoming request.
-     *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \Closure  $next
-     * @return mixed
-     */
-    public function handle($request, Closure $next)
-    {
-        $iframeHosts = collect(explode(' ', config('app.iframe_hosts', '')))->filter();
-        if ($iframeHosts->count() > 0) {
-            config()->set('session.same_site', 'none');
-        }
-
-        $iframeHosts->prepend("'self'");
-
-        $response = $next($request);
-        $cspValue = 'frame-ancestors ' . $iframeHosts->join(' ');
-        $response->headers->set('Content-Security-Policy', $cspValue);
-        return $response;
-    }
-}
index cd084746c5ca2b6ea432d5d2ab863e807bc94581..d8e1253e5edd5f925ffff25b258371974e58a1a0 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Middleware;
+<?php
+
+namespace BookStack\Http\Middleware;
 
 use Carbon\Carbon;
 use Closure;
@@ -6,54 +8,58 @@ use Illuminate\Http\Request;
 
 class Localization
 {
-
     /**
-     * Array of right-to-left locales
+     * Array of right-to-left locales.
      */
     protected $rtlLocales = ['ar', 'he'];
 
     /**
      * Map of BookStack locale names to best-estimate system locale names.
+     * Locales can often be found by running `locale -a` on a linux system.
      */
     protected $localeMap = [
-        'ar' => 'ar',
-        'bg' => 'bg_BG',
-        'bs' => 'bs_BA',
-        'ca' => 'ca',
-        'da' => 'da_DK',
-        'de' => 'de_DE',
+        'ar'          => 'ar',
+        'bg'          => 'bg_BG',
+        'bs'          => 'bs_BA',
+        'ca'          => 'ca',
+        'da'          => 'da_DK',
+        'de'          => 'de_DE',
         'de_informal' => 'de_DE',
-        'en' => 'en_GB',
-        'es' => 'es_ES',
-        'es_AR' => 'es_AR',
-        'fr' => 'fr_FR',
-        'he' => 'he_IL',
-        'id' => 'id_ID',
-        'it' => 'it_IT',
-        'ja' => 'ja',
-        'ko' => 'ko_KR',
-        'lv' => 'lv_LV',
-        'nl' => 'nl_NL',
-        'nb' => 'nb_NO',
-        'pl' => 'pl_PL',
-        'pt' => 'pt_PT',
-        'pt_BR' => 'pt_BR',
-        'ru' => 'ru',
-        'sk' => 'sk_SK',
-        'sl' => 'sl_SI',
-        'sv' => 'sv_SE',
-        'uk' => 'uk_UA',
-        'vi' => 'vi_VN',
-        'zh_CN' => 'zh_CN',
-        'zh_TW' => 'zh_TW',
-        'tr' => 'tr_TR',
+        'en'          => 'en_GB',
+        'es'          => 'es_ES',
+        'es_AR'       => 'es_AR',
+        'et'          => 'et_EE',
+        'fr'          => 'fr_FR',
+        'he'          => 'he_IL',
+        'hr'          => 'hr_HR',
+        'id'          => 'id_ID',
+        'it'          => 'it_IT',
+        'ja'          => 'ja',
+        'ko'          => 'ko_KR',
+        'lt'          => 'lt_LT',
+        'lv'          => 'lv_LV',
+        'nl'          => 'nl_NL',
+        'nb'          => 'nb_NO',
+        'pl'          => 'pl_PL',
+        'pt'          => 'pt_PT',
+        'pt_BR'       => 'pt_BR',
+        'ru'          => 'ru',
+        'sk'          => 'sk_SK',
+        'sl'          => 'sl_SI',
+        'sv'          => 'sv_SE',
+        'uk'          => 'uk_UA',
+        'vi'          => 'vi_VN',
+        'zh_CN'       => 'zh_CN',
+        'zh_TW'       => 'zh_TW',
+        'tr'          => 'tr_TR',
     ];
 
     /**
      * Handle an incoming request.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \Closure  $next
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
      * @return mixed
      */
     public function handle($request, Closure $next)
@@ -72,6 +78,7 @@ class Localization
         app()->setLocale($locale);
         Carbon::setLocale($locale);
         $this->setSystemDateLocale($locale);
+
         return $next($request);
     }
 
@@ -105,11 +112,12 @@ class Localization
                 return $lang;
             }
         }
+
         return $default;
     }
 
     /**
-     * Get the ISO version of a BookStack language name
+     * Get the ISO version of a BookStack language name.
      */
     public function getLocaleIso(string $locale): string
     {
diff --git a/app/Http/Middleware/PermissionMiddleware.php b/app/Http/Middleware/PermissionMiddleware.php
deleted file mode 100644 (file)
index d0bb4f7..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-
-namespace BookStack\Http\Middleware;
-
-use Closure;
-use Illuminate\Support\Facades\Session;
-
-class PermissionMiddleware
-{
-    /**
-     * Handle an incoming request.
-     *
-     * @param  \Illuminate\Http\Request $request
-     * @param  \Closure                 $next
-     * @param                           $permission
-     * @return mixed
-     */
-    public function handle($request, Closure $next, $permission)
-    {
-
-        if (!$request->user() || !$request->user()->can($permission)) {
-            session()->flash('error', trans('errors.permission'));
-            return redirect()->back();
-        }
-
-        return $next($request);
-    }
-}
diff --git a/app/Http/Middleware/PreventAuthenticatedResponseCaching.php b/app/Http/Middleware/PreventAuthenticatedResponseCaching.php
new file mode 100644 (file)
index 0000000..60c9138
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use Closure;
+use Symfony\Component\HttpFoundation\Response;
+
+class PreventAuthenticatedResponseCaching
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        /** @var Response $response */
+        $response = $next($request);
+
+        if (signedInUser()) {
+            $response->headers->set('Cache-Control', 'max-age=0, no-store, private');
+            $response->headers->set('Pragma', 'no-cache');
+            $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
+        }
+
+        return $response;
+    }
+}
similarity index 58%
rename from app/Http/Middleware/CheckForMaintenanceMode.php
rename to app/Http/Middleware/PreventRequestsDuringMaintenance.php
index 0c76838367e3adc9bb7336a6938b24fb0dfdda45..dfb9592e106ec3d6f0f3ea42d65cf8dbe585ef01 100644 (file)
@@ -2,9 +2,9 @@
 
 namespace BookStack\Http\Middleware;
 
-use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode as Middleware;
+use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
 
-class CheckForMaintenanceMode extends Middleware
+class PreventRequestsDuringMaintenance extends Middleware
 {
     /**
      * The URIs that should be reachable while maintenance mode is enabled.
index c27df7af4f5434bdbf7d208fbe98f82ddde78bfb..ee7fe880c7ad1f0c164531deebc4f82555c91241 100644 (file)
@@ -1,40 +1,31 @@
-<?php namespace BookStack\Http\Middleware;
+<?php
 
+namespace BookStack\Http\Middleware;
+
+use BookStack\Providers\RouteServiceProvider;
 use Closure;
-use Illuminate\Contracts\Auth\Guard;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
 
 class RedirectIfAuthenticated
 {
-    /**
-     * The Guard implementation.
-     *
-     * @var Guard
-     */
-    protected $auth;
-
-    /**
-     * Create a new filter instance.
-     *
-     * @param  Guard $auth
-     * @return void
-     */
-    public function __construct(Guard $auth)
-    {
-        $this->auth = $auth;
-    }
-
     /**
      * Handle an incoming request.
      *
-     * @param  \Illuminate\Http\Request $request
-     * @param  \Closure                 $next
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     * @param string|null              ...$guards
+     *
      * @return mixed
      */
-    public function handle($request, Closure $next)
+    public function handle(Request $request, Closure $next, ...$guards)
     {
-        $requireConfirmation = setting('registration-confirmation');
-        if ($this->auth->check() && (!$requireConfirmation || ($requireConfirmation && $this->auth->user()->email_confirmed))) {
-            return redirect('/');
+        $guards = empty($guards) ? [null] : $guards;
+
+        foreach ($guards as $guard) {
+            if (Auth::guard($guard)->check()) {
+                return redirect(RouteServiceProvider::HOME);
+            }
         }
 
         return $next($request);
index d995f144547219bf2eafcd1b5dff698cc9f1457b..5e727daae5e99f076c5b59e29ff7adb7bb03b068 100644 (file)
@@ -11,8 +11,9 @@ class RunThemeActions
     /**
      * Handle an incoming request.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \Closure  $next
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
      * @return mixed
      */
     public function handle($request, Closure $next)
@@ -24,6 +25,7 @@ class RunThemeActions
 
         $response = $next($request);
         $response = Theme::dispatch(ThemeEvents::WEB_MIDDLEWARE_AFTER, $request, $response) ?? $response;
+
         return $response;
     }
 }
index 4e03aed58d50113236f5fef509a89c43101efa8b..c63d0c6033d4b6918a9970a9f495af2f1432430e 100644 (file)
@@ -6,7 +6,6 @@ use Illuminate\Routing\Middleware\ThrottleRequests as Middleware;
 
 class ThrottleApiRequests extends Middleware
 {
-
     /**
      * Resolve the number of attempts if the user is authenticated or not.
      */
diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php
new file mode 100644 (file)
index 0000000..7bd89ee
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use Illuminate\Http\Middleware\TrustHosts as Middleware;
+
+class TrustHosts extends Middleware
+{
+    /**
+     * Get the host patterns that should be trusted.
+     *
+     * @return array
+     */
+    public function hosts()
+    {
+        return [
+            $this->allSubdomainsOfApplicationUrl(),
+        ];
+    }
+}
index 7b01d0aab0de906a01be4afe31a7f82b937b429e..0fe0247b829e8b4412dcbd42888c1f05bac60708 100644 (file)
@@ -3,7 +3,7 @@
 namespace BookStack\Http\Middleware;
 
 use Closure;
-use Fideloper\Proxy\TrustProxies as Middleware;
+use Illuminate\Http\Middleware\TrustProxies as Middleware;
 use Illuminate\Http\Request;
 
 class TrustProxies extends Middleware
@@ -20,12 +20,14 @@ class TrustProxies extends Middleware
      *
      * @var int
      */
-    protected $headers = Request::HEADER_X_FORWARDED_ALL;
+    protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;
 
     /**
      * Handle the request, Set the correct user-configured proxy information.
+     *
      * @param Request $request
      * @param Closure $next
+     *
      * @return mixed
      */
     public function handle(Request $request, Closure $next)
index bdeb265540a9bde5c6d53f6ffef8b5e8f1ca3711..804a22bc09a3e35acbe26833940eb215e45a81d7 100644 (file)
@@ -19,6 +19,6 @@ class VerifyCsrfToken extends Middleware
      * @var array
      */
     protected $except = [
-        'saml2/*'
+        'saml2/*',
     ];
 }
index 183686f67b4eef3a6299c2cb2e25893b4f4c4a61..c5b38f1c1bbfe1f391469d791d144ed400e1ce38 100644 (file)
@@ -1,13 +1,15 @@
-<?php namespace BookStack\Http;
+<?php
+
+namespace BookStack\Http;
 
 use Illuminate\Http\Request as LaravelRequest;
 
 class Request extends LaravelRequest
 {
-
     /**
      * Override the default request methods to get the scheme and host
      * to set the custom APP_URL, if set.
+     *
      * @return \Illuminate\Config\Repository|mixed|string
      */
     public function getSchemeAndHttpHost()
@@ -17,7 +19,7 @@ class Request extends LaravelRequest
         if ($base) {
             $base = trim($base, '/');
         } else {
-            $base = $this->getScheme().'://'.$this->getHttpHost();
+            $base = $this->getScheme() . '://' . $this->getHttpHost();
         }
 
         return $base;
diff --git a/app/Interfaces/Deletable.php b/app/Interfaces/Deletable.php
new file mode 100644 (file)
index 0000000..be9b4ac
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace BookStack\Interfaces;
+
+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
+{
+    public function deletions(): MorphMany;
+}
diff --git a/app/Interfaces/Favouritable.php b/app/Interfaces/Favouritable.php
new file mode 100644 (file)
index 0000000..8a311d1
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace BookStack\Interfaces;
+
+use Illuminate\Database\Eloquent\Relations\MorphMany;
+
+interface Favouritable
+{
+    /**
+     * Get the related favourite instances.
+     */
+    public function favourites(): MorphMany;
+}
index 6aa94e69c9a66e39340df51fd91d7ee8c546974a..2d56e847e388fcca3993d4ecb35f77f302106d10 100644 (file)
@@ -1,23 +1,18 @@
-<?php namespace BookStack\Interfaces;
+<?php
 
-use Illuminate\Database\Eloquent\Builder;
+namespace BookStack\Interfaces;
 
 /**
- * Interface Sluggable
- *
  * Assigned to models that can have slugs.
  * Must have the below properties.
  *
- * @property int $id
+ * @property int    $id
  * @property string $name
- * @method Builder newQuery
  */
 interface Sluggable
 {
-
     /**
      * Regenerate the slug for this model.
      */
     public function refreshSlug(): string;
-
-}
\ No newline at end of file
+}
diff --git a/app/Interfaces/Viewable.php b/app/Interfaces/Viewable.php
new file mode 100644 (file)
index 0000000..d79ebed
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace BookStack\Interfaces;
+
+use Illuminate\Database\Eloquent\Relations\MorphMany;
+
+interface Viewable
+{
+    /**
+     * Get all view instances for this viewable model.
+     */
+    public function views(): MorphMany;
+}
index 498bacb2055fcb4296b69a44f1e75eb3c61b9e11..6fdc250976586386005c37bf3ca333d14373ebb4 100644 (file)
@@ -1,17 +1,18 @@
-<?php namespace BookStack;
+<?php
+
+namespace BookStack;
 
 use Illuminate\Database\Eloquent\Model as EloquentModel;
 
 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.
-     * @param $key
+     *
      * @return mixed
      */
-    public function getRawAttribute($key)
+    public function getRawAttribute(string $key)
     {
         return parent::getAttributeFromArray($key);
     }
index 229408f5cf9827533a8307727beac5e4768185c7..5399c25a8492eca5bdbfd52236c439630e02d45c 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Notifications;
+<?php
+
+namespace BookStack\Notifications;
 
 class ConfirmEmail extends MailNotification
 {
@@ -6,6 +8,7 @@ class ConfirmEmail extends MailNotification
 
     /**
      * Create a new notification instance.
+     *
      * @param string $token
      */
     public function __construct($token)
@@ -16,12 +19,14 @@ class ConfirmEmail extends MailNotification
     /**
      * Get the mail representation of the notification.
      *
-     * @param  mixed  $notifiable
+     * @param mixed $notifiable
+     *
      * @return \Illuminate\Notifications\Messages\MailMessage
      */
     public function toMail($notifiable)
     {
         $appName = ['appName' => setting('app-name')];
+
         return $this->newMailMessage()
                 ->subject(trans('auth.email_confirm_subject', $appName))
                 ->greeting(trans('auth.email_confirm_greeting', $appName))
index 5aa9b1e4a6296de1fe9557649a2d7401d877c52c..12159b27893db094541219862fdebebe1f3fdc94 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Notifications;
+<?php
+
+namespace BookStack\Notifications;
 
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -12,7 +14,8 @@ class MailNotification extends Notification implements ShouldQueue
     /**
      * Get the notification's channels.
      *
-     * @param  mixed  $notifiable
+     * @param mixed $notifiable
+     *
      * @return array|string
      */
     public function via($notifiable)
@@ -22,13 +25,14 @@ class MailNotification extends Notification implements ShouldQueue
 
     /**
      * Create a new mail message.
+     *
      * @return MailMessage
      */
     protected function newMailMessage()
     {
-        return (new MailMessage)->view([
+        return (new MailMessage())->view([
             'html' => 'vendor.notifications.email',
-            'text' => 'vendor.notifications.email-plain'
+            'text' => 'vendor.notifications.email-plain',
         ]);
     }
 }
index 20875276400f0403d1a9a063459e37195a2cd475..7fa14659604f567ec04847ee761526c1dfdf2b0f 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Notifications;
+<?php
+
+namespace BookStack\Notifications;
 
 class ResetPassword extends MailNotification
 {
@@ -12,7 +14,7 @@ class ResetPassword extends MailNotification
     /**
      * Create a notification instance.
      *
-     * @param  string  $token
+     * @param string $token
      */
     public function __construct($token)
     {
@@ -26,7 +28,7 @@ class ResetPassword extends MailNotification
      */
     public function toMail()
     {
-            return $this->newMailMessage()
+        return $this->newMailMessage()
             ->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
             ->line(trans('auth.email_reset_text'))
             ->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
index 7fce1c19c80a7d2d6d590a949679eda6059b08b1..7f59ff70ffab3f44b5199da30228018955151794 100644 (file)
@@ -1,11 +1,14 @@
-<?php namespace BookStack\Notifications;
+<?php
+
+namespace BookStack\Notifications;
 
 class TestEmail extends MailNotification
 {
     /**
      * Get the mail representation of the notification.
      *
-     * @param  mixed  $notifiable
+     * @param mixed $notifiable
+     *
      * @return \Illuminate\Notifications\Messages\MailMessage
      */
     public function toMail($notifiable)
index b01911bcdc06780f31664d15bc5881d9d65aac2e..3bae32721ae69a4295e70395ba79c7a36c178e50 100644 (file)
@@ -1,4 +1,9 @@
-<?php namespace BookStack\Notifications;
+<?php
+
+namespace BookStack\Notifications;
+
+use BookStack\Auth\User;
+use Illuminate\Notifications\Messages\MailMessage;
 
 class UserInvite extends MailNotification
 {
@@ -6,26 +11,24 @@ class UserInvite extends MailNotification
 
     /**
      * Create a new notification instance.
-     * @param string $token
      */
-    public function __construct($token)
+    public function __construct(string $token)
     {
         $this->token = $token;
     }
 
     /**
      * Get the mail representation of the notification.
-     *
-     * @param  mixed  $notifiable
-     * @return \Illuminate\Notifications\Messages\MailMessage
      */
-    public function toMail($notifiable)
+    public function toMail(User $notifiable): MailMessage
     {
         $appName = ['appName' => setting('app-name')];
+        $language = setting()->getUser($notifiable, 'language');
+
         return $this->newMailMessage()
-                ->subject(trans('auth.user_invite_email_subject', $appName))
-                ->greeting(trans('auth.user_invite_email_greeting', $appName))
-                ->line(trans('auth.user_invite_email_text'))
-                ->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
+                ->subject(trans('auth.user_invite_email_subject', $appName, $language))
+                ->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
+                ->line(trans('auth.user_invite_email_text', [], $language))
+                ->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
     }
 }
index 333542c31e992771916faba7cca9da6f5760d3be..fc712632e9b876117f605d36d5bd0971f4290468 100644 (file)
@@ -1,21 +1,30 @@
-<?php namespace BookStack\Providers;
+<?php
 
-use Blade;
+namespace BookStack\Providers;
+
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\SocialAuthService;
+use BookStack\Entities\BreadcrumbsViewComposer;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\BreadcrumbsViewComposer;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
+use BookStack\Util\CspService;
+use GuzzleHttp\Client;
 use Illuminate\Contracts\Cache\Repository;
 use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Facades\Blade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\URL;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
 use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
-use Schema;
-use URL;
+use Psr\Http\Client\ClientInterface as HttpClientInterface;
+use Whoops\Handler\HandlerInterface;
 
 class AppServiceProvider extends ServiceProvider
 {
@@ -45,13 +54,16 @@ class AppServiceProvider extends ServiceProvider
         // Set morph-map due to namespace changes
         Relation::morphMap([
             'BookStack\\Bookshelf' => Bookshelf::class,
-            'BookStack\\Book' => Book::class,
-            'BookStack\\Chapter' => Chapter::class,
-            'BookStack\\Page' => Page::class,
+            'BookStack\\Book'      => Book::class,
+            'BookStack\\Chapter'   => Chapter::class,
+            'BookStack\\Page'      => Page::class,
         ]);
 
         // View Composers
-        View::composer('partials.breadcrumbs', BreadcrumbsViewComposer::class);
+        View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
+
+        // Set paginator to use bootstrap-style pagination
+        Paginator::useBootstrap();
     }
 
     /**
@@ -61,12 +73,26 @@ class AppServiceProvider extends ServiceProvider
      */
     public function register()
     {
+        $this->app->bind(HandlerInterface::class, function ($app) {
+            return $app->make(WhoopsBookStackPrettyHandler::class);
+        });
+
         $this->app->singleton(SettingService::class, function ($app) {
             return new SettingService($app->make(Setting::class), $app->make(Repository::class));
         });
 
-        $this->app->singleton(SocialAuthService::class, function($app) {
-            return new SocialAuthService($app->make(SocialiteFactory::class));
+        $this->app->singleton(SocialAuthService::class, function ($app) {
+            return new SocialAuthService($app->make(SocialiteFactory::class), $app->make(LoginService::class));
+        });
+
+        $this->app->singleton(CspService::class, function ($app) {
+            return new CspService();
+        });
+
+        $this->app->bind(HttpClientInterface::class, function ($app) {
+            return new Client([
+                'timeout' => 3,
+            ]);
         });
     }
 }
index fe52df1686cec7ac00bcd82ba14a1c74b0b5f3dd..b301604a519e9b95e5bf5881f7a93c0f78cfb88e 100644 (file)
@@ -2,15 +2,16 @@
 
 namespace BookStack\Providers;
 
-use Auth;
 use BookStack\Api\ApiTokenGuard;
 use BookStack\Auth\Access\ExternalBaseUserProvider;
+use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard;
 use BookStack\Auth\Access\Guards\LdapSessionGuard;
-use BookStack\Auth\Access\Guards\Saml2SessionGuard;
 use BookStack\Auth\Access\LdapService;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\RegistrationService;
-use BookStack\Auth\UserRepo;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\ServiceProvider;
+use Illuminate\Validation\Rules\Password;
 
 class AuthServiceProvider extends ServiceProvider
 {
@@ -21,27 +22,35 @@ class AuthServiceProvider extends ServiceProvider
      */
     public function boot()
     {
+        // Password Configuration
+        Password::defaults(function () {
+            return Password::min(8);
+        });
+
+        // Custom guards
         Auth::extend('api-token', function ($app, $name, array $config) {
-            return new ApiTokenGuard($app['request']);
+            return new ApiTokenGuard($app['request'], $app->make(LoginService::class));
         });
 
         Auth::extend('ldap-session', function ($app, $name, array $config) {
             $provider = Auth::createUserProvider($config['provider']);
+
             return new LdapSessionGuard(
                 $name,
                 $provider,
-                $this->app['session.store'],
+                $app['session.store'],
                 $app[LdapService::class],
                 $app[RegistrationService::class]
             );
         });
 
-        Auth::extend('saml2-session', function ($app, $name, array $config) {
+        Auth::extend('async-external-session', function ($app, $name, array $config) {
             $provider = Auth::createUserProvider($config['provider']);
-            return new Saml2SessionGuard(
+
+            return new AsyncExternalBaseSessionGuard(
                 $name,
                 $provider,
-                $this->app['session.store'],
+                $app['session.store'],
                 $app[RegistrationService::class]
             );
         });
index f203f0fda592f5e27e7099b851395207bf8de71b..0518af44f9bf6e4f55d4ae311a30313aa7b8f1db 100644 (file)
@@ -2,8 +2,7 @@
 
 namespace BookStack\Providers;
 
-use BookStack\Actions\ActivityService;
-use BookStack\Actions\ViewService;
+use BookStack\Actions\ActivityLogger;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Theming\ThemeService;
 use BookStack\Uploads\ImageService;
@@ -29,11 +28,7 @@ class CustomFacadeProvider extends ServiceProvider
     public function register()
     {
         $this->app->singleton('activity', function () {
-            return $this->app->make(ActivityService::class);
-        });
-
-        $this->app->singleton('views', function () {
-            return $this->app->make(ViewService::class);
+            return $this->app->make(ActivityLogger::class);
         });
 
         $this->app->singleton('images', function () {
index b668a4cd22b4312f6f7ce17649177de25a09ce1e..ac95099cc7a0298ba4704bee0ca38494a5ae60eb 100644 (file)
@@ -2,26 +2,28 @@
 
 namespace BookStack\Providers;
 
+use BookStack\Uploads\ImageService;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\ServiceProvider;
 
 class CustomValidationServiceProvider extends ServiceProvider
 {
-
     /**
      * Register our custom validation rules when the application boots.
      */
     public function boot(): void
     {
         Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
-            $validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
-            return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
+            $extension = strtolower($value->getClientOriginalExtension());
+
+            return ImageService::isExtensionSupported($extension);
         });
 
         Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
             $cleanLinkName = strtolower(trim($value));
             $isJs = strpos($cleanLinkName, 'javascript:') === 0;
             $isData = strpos($cleanLinkName, 'data:') === 0;
+
             return !$isJs && !$isData;
         });
     }
index a826185d82de7bdfa49a7db17bbd9b4ac6c3c84b..659843ce33bff2b022b70a121013accd15bd836f 100644 (file)
@@ -30,6 +30,5 @@ class EventServiceProvider extends ServiceProvider
      */
     public function boot()
     {
-        parent::boot();
     }
 }
index 1c982b82eacd26610861d49cfdcf71bc1e5a02c3..416aa5f347abdab63253cdc631d89f39e237c013 100644 (file)
@@ -1,11 +1,12 @@
-<?php namespace BookStack\Providers;
+<?php
+
+namespace BookStack\Providers;
 
 use Illuminate\Pagination\PaginationServiceProvider as IlluminatePaginationServiceProvider;
 use Illuminate\Pagination\Paginator;
 
 class PaginationServiceProvider extends IlluminatePaginationServiceProvider
 {
-
     /**
      * Register the service provider.
      *
index a37780e52eb1c25eaef4355afc3b497e786d9f6a..ac3307f2ded53c4799d8e8d1dcef465bd008da37 100644 (file)
@@ -2,11 +2,23 @@
 
 namespace BookStack\Providers;
 
+use Illuminate\Cache\RateLimiting\Limit;
 use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
-use Route;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\RateLimiter;
+use Illuminate\Support\Facades\Route;
 
 class RouteServiceProvider extends ServiceProvider
 {
+    /**
+     * The path to the "home" route for your application.
+     *
+     * This is used by Laravel authentication to redirect users after login.
+     *
+     * @var string
+     */
+    public const HOME = '/';
+
     /**
      * This namespace is applied to the controller routes in your routes file.
      *
@@ -14,7 +26,6 @@ class RouteServiceProvider extends ServiceProvider
      *
      * @var string
      */
-    protected $namespace = 'BookStack\Http\Controllers';
 
     /**
      * Define your route model bindings, pattern filters, etc.
@@ -23,19 +34,14 @@ class RouteServiceProvider extends ServiceProvider
      */
     public function boot()
     {
-        parent::boot();
-    }
+        $this->configureRateLimiting();
 
-    /**
-     * Define the routes for the application.
-     *
-     * @return void
-     */
-    public function map()
-    {
-        $this->mapWebRoutes();
-        $this->mapApiRoutes();
+        $this->routes(function () {
+            $this->mapWebRoutes();
+            $this->mapApiRoutes();
+        });
     }
+
     /**
      * Define the "web" routes for the application.
      *
@@ -47,11 +53,12 @@ class RouteServiceProvider extends ServiceProvider
     {
         Route::group([
             'middleware' => 'web',
-            'namespace' => $this->namespace,
+            'namespace'  => $this->namespace,
         ], function ($router) {
             require base_path('routes/web.php');
         });
     }
+
     /**
      * Define the "api" routes for the application.
      *
@@ -63,10 +70,22 @@ class RouteServiceProvider extends ServiceProvider
     {
         Route::group([
             'middleware' => 'api',
-            'namespace' => $this->namespace . '\Api',
-            'prefix' => 'api',
+            'namespace'  => $this->namespace . '\Api',
+            'prefix'     => 'api',
         ], function ($router) {
             require base_path('routes/api.php');
         });
     }
+
+    /**
+     * Configure the rate limiters for the application.
+     *
+     * @return void
+     */
+    protected function configureRateLimiting()
+    {
+        RateLimiter::for('api', function (Request $request) {
+            return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
+        });
+    }
 }
index c41a15af06af98fbd8446c7b5e8face78941390f..54c83884a77d969270ee07eded2222f40451cc2c 100644 (file)
@@ -16,7 +16,7 @@ class ThemeServiceProvider extends ServiceProvider
     public function register()
     {
         $this->app->singleton(ThemeService::class, function ($app) {
-            return new ThemeService;
+            return new ThemeService();
         });
     }
 
index b7fb9b117ac18896cbd8cebcc54c08d21350a563..3610a1e22148a5601a566198dfdcdcde3d8af97f 100644 (file)
@@ -1,14 +1,16 @@
-<?php namespace BookStack\Providers;
+<?php
+
+namespace BookStack\Providers;
 
 use BookStack\Translation\FileLoader;
 use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
 
 class TranslationServiceProvider extends BaseProvider
 {
-
     /**
      * Register the translation line loader.
      * Overrides the default register action from Laravel so a custom loader can be used.
+     *
      * @return void
      */
     protected function registerLoader()
index 1a52920eeb73cc6c88e78ad7ec68961ddd30e8cf..c0492e6c1f7ca9133de70ba042b4d0b5823a6038 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Settings;
+<?php
+
+namespace BookStack\Settings;
 
 use BookStack\Model;
 
index 310e0ccfff83d2898cb6b675d5924da233c4a0f0..f2c4c8305c47c2db227a79456e880422171dc500 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Settings;
+<?php
+
+namespace BookStack\Settings;
 
 use BookStack\Auth\User;
 use Illuminate\Contracts\Cache\Repository as Cache;
@@ -42,6 +44,7 @@ class SettingService
         $value = $this->getValueFromStore($key) ?? $default;
         $formatted = $this->formatValue($value, $default);
         $this->localCache[$key] = $formatted;
+
         return $formatted;
     }
 
@@ -51,6 +54,7 @@ class SettingService
     protected function getFromSession(string $key, $default = false)
     {
         $value = session()->get($key, $default);
+
         return $this->formatValue($value, $default);
     }
 
@@ -66,6 +70,7 @@ class SettingService
         if ($user->isDefault()) {
             return $this->getFromSession($key, $default);
         }
+
         return $this->get($this->userKey($user->id, $key), $default);
     }
 
@@ -101,6 +106,7 @@ class SettingService
             }
 
             $this->cache->forever($cacheKey, $value);
+
             return $value;
         }
 
@@ -120,14 +126,14 @@ class SettingService
     }
 
     /**
-     * Format a settings value
+     * Format a settings value.
      */
     protected function formatValue($value, $default)
     {
         // Change string booleans to actual booleans
         if ($value === 'true') {
             $value = true;
-        } else if ($value === 'false') {
+        } elseif ($value === 'false') {
             $value = false;
         }
 
@@ -135,6 +141,7 @@ class SettingService
         if ($value === '') {
             $value = $default;
         }
+
         return $value;
     }
 
@@ -144,6 +151,7 @@ class SettingService
     public function has(string $key): bool
     {
         $setting = $this->getSettingObjectByKey($key);
+
         return $setting !== null;
     }
 
@@ -154,7 +162,7 @@ class SettingService
     public function put(string $key, $value): bool
     {
         $setting = $this->setting->newQuery()->firstOrNew([
-            'setting_key' => $key
+            'setting_key' => $key,
         ]);
         $setting->type = 'string';
 
@@ -166,6 +174,7 @@ class SettingService
         $setting->value = $value;
         $setting->save();
         $this->clearFromCache($key);
+
         return true;
     }
 
@@ -179,6 +188,7 @@ class SettingService
         $values = collect($value)->values()->filter(function (array $item) {
             return count(array_filter($item)) > 0;
         });
+
         return json_encode($values);
     }
 
@@ -189,6 +199,7 @@ class SettingService
     {
         if ($user->isDefault()) {
             session()->put($key, $value);
+
             return true;
         }
 
diff --git a/app/Theming/CustomHtmlHeadContentProvider.php b/app/Theming/CustomHtmlHeadContentProvider.php
new file mode 100644 (file)
index 0000000..041e5d0
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+namespace BookStack\Theming;
+
+use BookStack\Util\CspService;
+use BookStack\Util\HtmlContentFilter;
+use BookStack\Util\HtmlNonceApplicator;
+use Illuminate\Contracts\Cache\Repository as Cache;
+
+class CustomHtmlHeadContentProvider
+{
+    /**
+     * @var CspService
+     */
+    protected $cspService;
+
+    /**
+     * @var Cache
+     */
+    protected $cache;
+
+    public function __construct(CspService $cspService, Cache $cache)
+    {
+        $this->cspService = $cspService;
+        $this->cache = $cache;
+    }
+
+    /**
+     * Fetch our custom HTML head content prepared for use on web pages.
+     * Content has a nonce applied for CSP.
+     */
+    public function forWeb(): string
+    {
+        $content = $this->getSourceContent();
+        $hash = md5($content);
+        $html = $this->cache->remember('custom-head-web:' . $hash, 86400, function () use ($content) {
+            return HtmlNonceApplicator::prepare($content);
+        });
+
+        return HtmlNonceApplicator::apply($html, $this->cspService->getNonce());
+    }
+
+    /**
+     * Fetch our custom HTML head content prepared for use in export formats.
+     * Scripts are stripped to avoid potential issues.
+     */
+    public function forExport(): string
+    {
+        $content = $this->getSourceContent();
+        $hash = md5($content);
+
+        return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
+            return HtmlContentFilter::removeScripts($content);
+        });
+    }
+
+    /**
+     * Get the original custom head content to use.
+     */
+    protected function getSourceContent(): string
+    {
+        return setting('app-custom-head', '');
+    }
+}
index 56e1fba1ccd70f4a3342047f6d20ec13d6f4822b..48073416ca6d72661685cae210623e0ea5faf53b 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Theming;
+<?php
+
+namespace BookStack\Theming;
 
 /**
  * The ThemeEvents used within BookStack.
@@ -16,6 +18,7 @@ class ThemeEvents
     /**
      * Application boot-up.
      * After main services are registered.
+     *
      * @param \BookStack\Application $app
      */
     const APP_BOOT = 'app_boot';
@@ -26,6 +29,7 @@ class ThemeEvents
      * that depend on the current session user (Localization for example).
      * Provides the original request to use.
      * Return values, if provided, will be used as a new response to use.
+     *
      * @param \Illuminate\Http\Request $request
      * @returns \Illuminate\Http\Response|null
      */
@@ -36,7 +40,9 @@ class ThemeEvents
      * Runs after the request is handled but before the response is sent.
      * Provides both the original request and the currently resolved response.
      * Return values, if provided, will be used as a new response to use.
-     * @param \Illuminate\Http\Request $request
+     *
+     * @param \Illuminate\Http\Request                                                      $request
+     * @param \Illuminate\Http\Response|Symfony\Component\HttpFoundation\BinaryFileResponse $response
      * @returns \Illuminate\Http\Response|null
      */
     const WEB_MIDDLEWARE_AFTER = 'web_middleware_after';
@@ -46,7 +52,8 @@ class ThemeEvents
      * Runs right after a user is logged-in to the application by any authentication
      * system as a standard app user. This includes a user becoming logged in
      * after registration. This is not emitted upon API usage.
-     * @param string $authSystem
+     *
+     * @param string               $authSystem
      * @param \BookStack\Auth\User $user
      */
     const AUTH_LOGIN = 'auth_login';
@@ -56,7 +63,8 @@ class ThemeEvents
      * Runs right after a user is newly registered to the application by any authentication
      * system as a standard app user. This includes auto-registration systems used
      * by LDAP, SAML and social systems. It only includes self-registrations.
-     * @param string $authSystem
+     *
+     * @param string               $authSystem
      * @param \BookStack\Auth\User $user
      */
     const AUTH_REGISTER = 'auth_register';
@@ -66,8 +74,25 @@ class ThemeEvents
      * Provides the commonmark library environment for customization
      * before its used to render markdown content.
      * If the listener returns a non-null value, that will be used as an environment instead.
+     *
      * @param \League\CommonMark\ConfigurableEnvironmentInterface $environment
      * @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
      */
     const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
-}
\ No newline at end of file
+
+    /**
+     * Webhook call before event.
+     * Runs before a webhook endpoint is called. Allows for customization
+     * of the data format & content within the webhook POST request.
+     * Provides the original event name as a string (see \BookStack\Actions\ActivityType)
+     * along with the webhook instance along with the event detail which may be a
+     * "Loggable" model type or a string.
+     * If the listener returns a non-null value, that will be used as the POST data instead
+     * of the system default.
+     *
+     * @param string                                $event
+     * @param \BookStack\Actions\Webhook            $webhook
+     * @param string|\BookStack\Interfaces\Loggable $detail
+     */
+    const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
+}
index 54e476ae2a896b65abaeafa2689406659affa1a1..275dc9d8c94d421fa4af8467e98146e73fa4404b 100644 (file)
@@ -1,6 +1,11 @@
-<?php namespace BookStack\Theming;
+<?php
+
+namespace BookStack\Theming;
 
 use BookStack\Auth\Access\SocialAuthService;
+use Illuminate\Console\Application;
+use Illuminate\Console\Application as Artisan;
+use Symfony\Component\Console\Command\Command;
 
 class ThemeService
 {
@@ -26,6 +31,7 @@ class ThemeService
      *
      * If a callback returns a non-null value, this method will
      * stop and return that value itself.
+     *
      * @return mixed
      */
     public function dispatch(string $event, ...$args)
@@ -36,16 +42,27 @@ class ThemeService
                 return $result;
             }
         }
+
         return null;
     }
 
+    /**
+     * Register a new custom artisan command to be available.
+     */
+    public function registerCommand(Command $command)
+    {
+        Artisan::starting(function (Application $application) use ($command) {
+            $application->addCommands([$command]);
+        });
+    }
+
     /**
      * Read any actions from the set theme path if the 'functions.php' file exists.
      */
     public function readThemeActions()
     {
         $themeActionsFile = theme_path('functions.php');
-        if (file_exists($themeActionsFile)) {
+        if ($themeActionsFile && file_exists($themeActionsFile)) {
             require $themeActionsFile;
         }
     }
@@ -53,9 +70,9 @@ class ThemeService
     /**
      * @see SocialAuthService::addSocialDriver
      */
-    public function addSocialDriver(string $driverName, array $config, string $socialiteHandler)
+    public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null)
     {
         $socialAuthService = app()->make(SocialAuthService::class);
-        $socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler);
+        $socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
     }
-}
\ No newline at end of file
+}
index ace1fa12cb47781550afa38a682b1b4bf12fc2eb..7c60be750059d048251c6ee41574d3b962e5b7c1 100644 (file)
@@ -1,11 +1,13 @@
-<?php namespace BookStack\Traits;
+<?php
+
+namespace BookStack\Traits;
 
 use BookStack\Auth\User;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * @property int created_by
- * @property int updated_by
+ * @property int $created_by
+ * @property int $updated_by
  */
 trait HasCreatorAndUpdater
 {
index ff4b8c18ec9d1b9e4bb43b24cdea1c09f29446f5..c0fefafa9cb7dc6fa60a6fd325b98e973ab1c669 100644 (file)
@@ -1,10 +1,12 @@
-<?php namespace BookStack\Traits;
+<?php
+
+namespace BookStack\Traits;
 
 use BookStack\Auth\User;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * @property int owned_by
+ * @property int $owned_by
  */
 trait HasOwner
 {
index 0b4a93de6bc8dc84b9145378a2830f2645160c88..de1124046183186ffeca53be74b1e3db3399293d 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Translation;
+<?php
+
+namespace BookStack\Translation;
 
 use Illuminate\Translation\FileLoader as BaseLoader;
 
@@ -8,9 +10,11 @@ class FileLoader extends BaseLoader
      * Load the messages for the given locale.
      * Extends Laravel's translation FileLoader to look in multiple directories
      * so that we can load in translation overrides from the theme file if wanted.
-     * @param  string  $locale
-     * @param  string  $group
-     * @param  string|null  $namespace
+     *
+     * @param string      $locale
+     * @param string      $group
+     * @param string|null $namespace
+     *
      * @return array
      */
     public function load($locale, $group, $namespace = null)
@@ -20,8 +24,10 @@ class FileLoader extends BaseLoader
         }
 
         if (is_null($namespace) || $namespace === '*') {
-            $themeTranslations = $this->loadPath(theme_path('lang'), $locale, $group);
-            $originalTranslations =  $this->loadPath($this->path, $locale, $group);
+            $themePath = theme_path('lang');
+            $themeTranslations = $themePath ? $this->loadPath($themePath, $locale, $group) : [];
+            $originalTranslations = $this->loadPath($this->path, $locale, $group);
+
             return array_merge($originalTranslations, $themeTranslations);
         }
 
index d1060477d085d3cda5c23b7363c5d54067de7b3d..5e637246a66b25b04f6256b92f7b8e1fca37778f 100644 (file)
@@ -1,24 +1,42 @@
-<?php namespace BookStack\Uploads;
+<?php
 
+namespace BookStack\Uploads;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Model;
 use BookStack\Traits\HasCreatorAndUpdater;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
- * @property int id
- * @property string name
- * @property string path
- * @property string extension
- * @property bool external
+ * @property int    $id
+ * @property string $name
+ * @property string $path
+ * @property string $extension
+ * @property ?Page  $page
+ * @property bool   $external
+ * @property int    $uploaded_to
+ * @property User   $updatedBy
+ * @property User   $createdBy
+ *
+ * @method static Entity|Builder visible()
  */
 class Attachment extends Model
 {
     use HasCreatorAndUpdater;
 
     protected $fillable = ['name', 'order'];
+    protected $hidden = ['path', 'page'];
+    protected $casts = [
+        'external' => 'bool',
+    ];
 
     /**
      * Get the downloadable file name for this upload.
+     *
      * @return mixed|string
      */
     public function getFileName()
@@ -26,14 +44,14 @@ class Attachment extends Model
         if (strpos($this->name, '.') !== false) {
             return $this->name;
         }
+
         return $this->name . '.' . $this->extension;
     }
 
     /**
      * Get the page this file was uploaded to.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function page()
+    public function page(): BelongsTo
     {
         return $this->belongsTo(Page::class, 'uploaded_to');
     }
@@ -41,12 +59,13 @@ class Attachment extends Model
     /**
      * Get the url of this file.
      */
-    public function getUrl(): string
+    public function getUrl($openInline = false): string
     {
         if ($this->external && strpos($this->path, 'http') !== 0) {
             return $this->path;
         }
-        return url('/attachments/' . $this->id);
+
+        return url('/attachments/' . $this->id . ($openInline ? '?open=true' : ''));
     }
 
     /**
@@ -54,7 +73,7 @@ class Attachment extends Model
      */
     public function htmlLink(): string
     {
-        return '<a target="_blank" href="'.e($this->getUrl()).'">'.e($this->name).'</a>';
+        return '<a target="_blank" href="' . e($this->getUrl()) . '">' . e($this->name) . '</a>';
     }
 
     /**
@@ -62,6 +81,21 @@ class Attachment extends Model
      */
     public function markdownLink(): string
     {
-        return '['. $this->name .']('. $this->getUrl() .')';
+        return '[' . $this->name . '](' . $this->getUrl() . ')';
+    }
+
+    /**
+     * Scope the query to those attachments that are visible based upon related page permissions.
+     */
+    public function scopeVisible(): Builder
+    {
+        $permissionService = app()->make(PermissionService::class);
+
+        return $permissionService->filterRelatedEntity(
+            Page::class,
+            self::query(),
+            'attachments',
+            'uploaded_to'
+        );
     }
 }
index 4437897c711e49527035d78478e0a95625b2bcbb..7974d7ae926b1472f61f567811e527acc254e688 100644 (file)
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Exceptions\FileUploadException;
 use Exception;
-use Illuminate\Contracts\Filesystem\Factory as FileSystem;
-use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
+use Illuminate\Contracts\Filesystem\Filesystem as Storage;
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
+use League\Flysystem\Util;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class AttachmentService
 {
-
     protected $fileSystem;
 
     /**
      * AttachmentService constructor.
      */
-    public function __construct(FileSystem $fileSystem)
+    public function __construct(FilesystemManager $fileSystem)
     {
         $this->fileSystem = $fileSystem;
     }
 
-
     /**
      * Get the storage that will be used for storing files.
      */
-    protected function getStorage(): FileSystemInstance
+    protected function getStorageDisk(): Storage
+    {
+        return $this->fileSystem->disk($this->getStorageDiskName());
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getStorageDiskName(): string
     {
         $storageType = config('filesystems.attachments');
 
-        // Override default location if set to local public to ensure not visible.
-        if ($storageType === 'local') {
-            $storageType = 'local_secure';
+        // Change to our secure-attachment disk if any of the local options
+        // are used to prevent escaping that location.
+        if ($storageType === 'local' || $storageType === 'local_secure') {
+            $storageType = 'local_secure_attachments';
+        }
+
+        return $storageType;
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForStorageDisk(string $path): string
+    {
+        $path = Util::normalizePath(str_replace('uploads/files/', '', $path));
+
+        if ($this->getStorageDiskName() === 'local_secure_attachments') {
+            return $path;
         }
 
-        return $this->fileSystem->disk($storageType);
+        return 'uploads/files/' . $path;
     }
 
     /**
      * Get an attachment from storage.
-     * @param Attachment $attachment
-     * @return string
-     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+     *
+     * @throws FileNotFoundException
      */
-    public function getAttachmentFromStorage(Attachment $attachment)
+    public function getAttachmentFromStorage(Attachment $attachment): string
     {
-        return $this->getStorage()->get($attachment->path);
+        return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path));
     }
 
     /**
      * Store a new attachment upon user upload.
-     * @param UploadedFile $uploadedFile
-     * @param int $page_id
-     * @return Attachment
+     *
      * @throws FileUploadException
      */
-    public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
+    public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment
     {
         $attachmentName = $uploadedFile->getClientOriginalName();
         $attachmentPath = $this->putFileInStorage($uploadedFile);
-        $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
-
-        $attachment = Attachment::forceCreate([
-            'name' => $attachmentName,
-            'path' => $attachmentPath,
-            'extension' => $uploadedFile->getClientOriginalExtension(),
-            'uploaded_to' => $page_id,
-            'created_by' => user()->id,
-            'updated_by' => user()->id,
-            'order' => $largestExistingOrder + 1
+        $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order');
+
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->forceCreate([
+            'name'        => $attachmentName,
+            'path'        => $attachmentPath,
+            'extension'   => $uploadedFile->getClientOriginalExtension(),
+            'uploaded_to' => $pageId,
+            'created_by'  => user()->id,
+            'updated_by'  => user()->id,
+            'order'       => $largestExistingOrder + 1,
         ]);
 
         return $attachment;
     }
 
     /**
-     * Store a upload, saving to a file and deleting any existing uploads
+     * Store an upload, saving to a file and deleting any existing uploads
      * attached to that file.
-     * @param UploadedFile $uploadedFile
-     * @param Attachment $attachment
-     * @return Attachment
+     *
      * @throws FileUploadException
      */
-    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
+    public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
     {
         if (!$attachment->external) {
             $this->deleteFileInStorage($attachment);
@@ -95,6 +118,7 @@ class AttachmentService
         $attachment->external = false;
         $attachment->extension = $uploadedFile->getClientOriginalExtension();
         $attachment->save();
+
         return $attachment;
     }
 
@@ -104,15 +128,16 @@ class AttachmentService
     public function saveNewFromLink(string $name, string $link, int $page_id): Attachment
     {
         $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
+
         return Attachment::forceCreate([
-            'name' => $name,
-            'path' => $link,
-            'external' => true,
-            'extension' => '',
+            'name'        => $name,
+            'path'        => $link,
+            'external'    => true,
+            'extension'   => '',
             'uploaded_to' => $page_id,
-            'created_by' => user()->id,
-            'updated_by' => user()->id,
-            'order' => $largestExistingOrder + 1
+            'created_by'  => user()->id,
+            'updated_by'  => user()->id,
+            'order'       => $largestExistingOrder + 1,
         ]);
     }
 
@@ -128,84 +153,92 @@ class AttachmentService
         }
     }
 
-
     /**
      * Update the details of a file.
      */
     public function updateFile(Attachment $attachment, array $requestData): Attachment
     {
         $attachment->name = $requestData['name'];
+        $link = trim($requestData['link'] ?? '');
 
-        if (isset($requestData['link']) && trim($requestData['link']) !== '') {
-            $attachment->path = $requestData['link'];
+        if (!empty($link)) {
             if (!$attachment->external) {
                 $this->deleteFileInStorage($attachment);
                 $attachment->external = true;
+                $attachment->extension = '';
             }
+            $attachment->path = $requestData['link'];
         }
 
         $attachment->save();
-        return $attachment;
+
+        return $attachment->refresh();
     }
 
     /**
      * Delete a File from the database and storage.
-     * @param Attachment $attachment
+     *
      * @throws Exception
      */
     public function deleteFile(Attachment $attachment)
     {
-        if ($attachment->external) {
-            $attachment->delete();
-            return;
+        if (!$attachment->external) {
+            $this->deleteFileInStorage($attachment);
         }
-        
-        $this->deleteFileInStorage($attachment);
+
         $attachment->delete();
     }
 
     /**
      * Delete a file from the filesystem it sits on.
      * Cleans any empty leftover folders.
-     * @param Attachment $attachment
      */
     protected function deleteFileInStorage(Attachment $attachment)
     {
-        $storage = $this->getStorage();
-        $dirPath = dirname($attachment->path);
+        $storage = $this->getStorageDisk();
+        $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
 
-        $storage->delete($attachment->path);
+        $storage->delete($this->adjustPathForStorageDisk($attachment->path));
         if (count($storage->allFiles($dirPath)) === 0) {
             $storage->deleteDirectory($dirPath);
         }
     }
 
     /**
-     * Store a file in storage with the given filename
-     * @param UploadedFile $uploadedFile
-     * @return string
+     * Store a file in storage with the given filename.
+     *
      * @throws FileUploadException
      */
-    protected function putFileInStorage(UploadedFile $uploadedFile)
+    protected function putFileInStorage(UploadedFile $uploadedFile): string
     {
         $attachmentData = file_get_contents($uploadedFile->getRealPath());
 
-        $storage = $this->getStorage();
-        $basePath = 'uploads/files/' . Date('Y-m-M') . '/';
+        $storage = $this->getStorageDisk();
+        $basePath = 'uploads/files/' . date('Y-m-M') . '/';
 
-        $uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
-        while ($storage->exists($basePath . $uploadFileName)) {
+        $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
+        while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
             $uploadFileName = Str::random(3) . $uploadFileName;
         }
 
         $attachmentPath = $basePath . $uploadFileName;
+
         try {
-            $storage->put($attachmentPath, $attachmentData);
+            $storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
         } catch (Exception $e) {
-            \Log::error('Error when attempting file upload:' . $e->getMessage());
+            Log::error('Error when attempting file upload:' . $e->getMessage());
+
             throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
         }
 
         return $attachmentPath;
     }
+
+    /**
+     * Get the file validation rules for attachments.
+     */
+    public function getFileValidationRules(): array
+    {
+        return ['file', 'max:' . (config('app.upload_limit') * 1000)];
+    }
 }
index 5e8115637dbbe23abf78dd5a9eb52959d9e75fe8..4198bb2a326dce7b3e28d6083a6dc6422daf86ee 100644 (file)
@@ -1,23 +1,27 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Exceptions\HttpFetchException;
 
 class HttpFetcher
 {
-
     /**
      * Fetch content from an external URI.
+     *
      * @param string $uri
-     * @return bool|string
+     *
      * @throws HttpFetchException
+     *
+     * @return bool|string
      */
     public function fetch(string $uri)
     {
         $ch = curl_init();
         curl_setopt_array($ch, [
-            CURLOPT_URL => $uri,
+            CURLOPT_URL            => $uri,
             CURLOPT_RETURNTRANSFER => 1,
-            CURLOPT_CONNECTTIMEOUT => 5
+            CURLOPT_CONNECTTIMEOUT => 5,
         ]);
 
         $data = curl_exec($ch);
index dc26af002ab5e29de70d3679d9a56282b39bb458..bdf10f080fe99cd991f52bc0174e75cc4c5ac00f 100644 (file)
@@ -1,12 +1,25 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Model;
 use BookStack\Traits\HasCreatorAndUpdater;
-use Images;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 
+/**
+ * @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
+ */
 class Image extends Model
 {
+    use HasFactory;
     use HasCreatorAndUpdater;
 
     protected $fillable = ['name'];
@@ -14,23 +27,19 @@ class Image extends Model
 
     /**
      * Get a thumbnail for this image.
-     * @param  int $width
-     * @param  int $height
-     * @param bool|false $keepRatio
-     * @return string
+     *
      * @throws \Exception
      */
-    public function getThumb($width, $height, $keepRatio = false)
+    public function getThumb(int $width, int $height, bool $keepRatio = false): string
     {
-        return Images::getThumbnail($this, $width, $height, $keepRatio);
+        return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio);
     }
 
     /**
      * Get the page this image has been uploaded to.
      * Only applicable to gallery or drawio image types.
-     * @return Page|null
      */
-    public function getPage()
+    public function getPage(): ?Page
     {
         return $this->belongsTo(Page::class, 'uploaded_to')->first();
     }
index e6f7668241dafa4dcfad7a0b852bfc6d214904c8..bfe4b597739a8e26f423c4f5f04d9d78f0db805f 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Models\Page;
@@ -9,34 +11,24 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImageRepo
 {
-
-    protected $image;
     protected $imageService;
     protected $restrictionService;
-    protected $page;
 
     /**
      * ImageRepo constructor.
      */
-    public function __construct(
-        Image $image,
-        ImageService $imageService,
-        PermissionService $permissionService,
-        Page $page
-    ) {
-        $this->image = $image;
+    public function __construct(ImageService $imageService, PermissionService $permissionService)
+    {
         $this->imageService = $imageService;
         $this->restrictionService = $permissionService;
-        $this->page = $page;
     }
 
-
     /**
      * Get an image with the given id.
      */
     public function getById($id): Image
     {
-        return $this->image->findOrFail($id);
+        return Image::query()->findOrFail($id);
     }
 
     /**
@@ -49,13 +41,13 @@ class ImageRepo
         $hasMore = count($images) > $pageSize;
 
         $returnImages = $images->take($pageSize);
-        $returnImages->each(function ($image) {
+        $returnImages->each(function (Image $image) {
             $this->loadThumbs($image);
         });
 
         return [
-            'images'  => $returnImages,
-            'has_more' => $hasMore
+            'images'   => $returnImages,
+            'has_more' => $hasMore,
         ];
     }
 
@@ -71,7 +63,7 @@ class ImageRepo
         string $search = null,
         callable $whereClause = null
     ): array {
-        $imageQuery = $this->image->newQuery()->where('type', '=', strtolower($type));
+        $imageQuery = Image::query()->where('type', '=', strtolower($type));
 
         if ($uploadedTo !== null) {
             $imageQuery = $imageQuery->where('uploaded_to', '=', $uploadedTo);
@@ -102,7 +94,8 @@ class ImageRepo
         int $uploadedTo = null,
         string $search = null
     ): array {
-        $contextPage = $this->page->findOrFail($uploadedTo);
+        /** @var Page $contextPage */
+        $contextPage = Page::visible()->findOrFail($uploadedTo);
         $parentFilter = null;
 
         if ($filterType === 'book' || $filterType === 'page') {
@@ -110,7 +103,10 @@ class ImageRepo
                 if ($filterType === 'page') {
                     $query->where('uploaded_to', '=', $contextPage->id);
                 } elseif ($filterType === 'book') {
-                    $validPageIds = $contextPage->book->pages()->visible()->get(['id'])->pluck('id')->toArray();
+                    $validPageIds = $contextPage->book->pages()
+                        ->scopes('visible')
+                        ->pluck('id')
+                        ->toArray();
                     $query->whereIn('uploaded_to', $validPageIds);
                 }
             };
@@ -121,29 +117,45 @@ class ImageRepo
 
     /**
      * Save a new image into storage and return the new image.
+     *
      * @throws ImageUploadException
      */
     public function saveNew(UploadedFile $uploadFile, string $type, int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, bool $keepRatio = true): Image
     {
         $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
         $this->loadThumbs($image);
+
+        return $image;
+    }
+
+    /**
+     * Save a new image from an existing image data string.
+     *
+     * @throws ImageUploadException
+     */
+    public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
+    {
+        $image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
+        $this->loadThumbs($image);
+
         return $image;
     }
 
     /**
-     * Save a drawing the the database.
+     * Save a drawing in the database.
+     *
      * @throws ImageUploadException
      */
     public function saveDrawing(string $base64Uri, int $uploadedTo): Image
     {
-        $name = 'Drawing-' . strval(user()->id) . '-' . strval(time()) . '.png';
+        $name = 'Drawing-' . user()->id . '-' . time() . '.png';
+
         return $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
     }
 
-
     /**
      * Update the details of an image via an array of properties.
-     * @throws ImageUploadException
+     *
      * @throws Exception
      */
     public function updateImageDetails(Image $image, $updateDetails): Image
@@ -151,53 +163,52 @@ class ImageRepo
         $image->fill($updateDetails);
         $image->save();
         $this->loadThumbs($image);
+
         return $image;
     }
 
     /**
      * Destroys an Image object along with its revisions, files and thumbnails.
+     *
      * @throws Exception
      */
-    public function destroyImage(Image $image = null): bool
+    public function destroyImage(Image $image = null): void
     {
         if ($image) {
             $this->imageService->destroy($image);
         }
-        return true;
     }
 
     /**
      * Destroy all images of a certain type.
+     *
      * @throws Exception
      */
-    public function destroyByType(string $imageType)
+    public function destroyByType(string $imageType): void
     {
-        $images = $this->image->where('type', '=', $imageType)->get();
+        $images = Image::query()->where('type', '=', $imageType)->get();
         foreach ($images as $image) {
             $this->destroyImage($image);
         }
     }
 
-
     /**
      * Load thumbnails onto an image object.
-     * @throws Exception
      */
-    public function loadThumbs(Image $image)
+    public function loadThumbs(Image $image): void
     {
-        $image->thumbs = [
+        $image->setAttribute('thumbs', [
             'gallery' => $this->getThumbnail($image, 150, 150, false),
-            'display' => $this->getThumbnail($image, 1680, null, true)
-        ];
+            'display' => $this->getThumbnail($image, 1680, null, true),
+        ]);
     }
 
     /**
      * Get the thumbnail for an image.
      * 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
      */
-    protected function getThumbnail(Image $image, ?int $width = 220, ?int $height = 220, bool $keepRatio = false): ?string
+    protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio): ?string
     {
         try {
             return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
@@ -228,7 +239,7 @@ class ImageRepo
             ->get(['id', 'name', 'slug', 'book_id']);
 
         foreach ($pages as $page) {
-            $page->url = $page->getUrl();
+            $page->setAttribute('url', $page->getUrl());
         }
 
         return $pages->all();
index 7793aaa01ee7363f01d9b9b62215dbc65092ba4d..e755be7e6e7c261adfd8e49ef1d344957983013f 100644 (file)
@@ -1,17 +1,24 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Exceptions\ImageUploadException;
-use DB;
 use ErrorException;
 use Exception;
 use Illuminate\Contracts\Cache\Repository as Cache;
-use Illuminate\Contracts\Filesystem\Factory as FileSystem;
-use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
 use Illuminate\Contracts\Filesystem\FileNotFoundException;
+use Illuminate\Contracts\Filesystem\Filesystem as Storage;
+use Illuminate\Filesystem\FilesystemAdapter;
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 use Intervention\Image\Exception\NotSupportedException;
 use Intervention\Image\ImageManager;
+use League\Flysystem\Util;
+use Psr\SimpleCache\InvalidArgumentException;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageService
 {
@@ -21,10 +28,12 @@ class ImageService
     protected $image;
     protected $fileSystem;
 
+    protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+
     /**
      * ImageService constructor.
      */
-    public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
+    public function __construct(Image $image, ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
     {
         $this->image = $image;
         $this->imageTool = $imageTool;
@@ -35,22 +44,60 @@ class ImageService
     /**
      * Get the storage that will be used for storing images.
      */
-    protected function getStorage(string $type = ''): FileSystemInstance
+    protected function getStorageDisk(string $imageType = ''): Storage
+    {
+        return $this->fileSystem->disk($this->getStorageDiskName($imageType));
+    }
+
+    /**
+     * Check if local secure image storage (Fetched behind authentication)
+     * is currently active in the instance.
+     */
+    protected function usingSecureImages(): bool
+    {
+        return $this->getStorageDiskName('gallery') === 'local_secure_images';
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
+    {
+        $path = Util::normalizePath(str_replace('uploads/images/', '', $path));
+
+        if ($this->getStorageDiskName($imageType) === 'local_secure_images') {
+            return $path;
+        }
+
+        return 'uploads/images/' . $path;
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getStorageDiskName(string $imageType): string
     {
         $storageType = config('filesystems.images');
 
         // Ensure system images (App logo) are uploaded to a public space
-        if ($type === 'system' && $storageType === 'local_secure') {
+        if ($imageType === 'system' && $storageType === 'local_secure') {
             $storageType = 'local';
         }
 
-        return $this->fileSystem->disk($storageType);
+        if ($storageType === 'local_secure') {
+            $storageType = 'local_secure_images';
+        }
+
+        return $storageType;
     }
 
     /**
      * Saves a new image from an upload.
-     * @return mixed
+     *
      * @throws ImageUploadException
+     *
+     * @return mixed
      */
     public function saveNewFromUpload(
         UploadedFile $uploadedFile,
@@ -72,31 +119,34 @@ class ImageService
 
     /**
      * Save a new image from a uri-encoded base64 string of data.
+     *
      * @throws ImageUploadException
      */
     public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, int $uploadedTo = 0): Image
     {
         $splitData = explode(';base64,', $base64Uri);
         if (count($splitData) < 2) {
-            throw new ImageUploadException("Invalid base64 image data provided");
+            throw new ImageUploadException('Invalid base64 image data provided');
         }
         $data = base64_decode($splitData[1]);
+
         return $this->saveNew($name, $data, $type, $uploadedTo);
     }
 
     /**
      * Save a new image into storage.
+     *
      * @throws ImageUploadException
      */
     public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
-        $storage = $this->getStorage($type);
+        $storage = $this->getStorageDisk($type);
         $secureUploads = setting('app-secure-images');
         $fileName = $this->cleanImageFileName($imageName);
 
-        $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
+        $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
 
-        while ($storage->exists($imagePath . $fileName)) {
+        while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
             $fileName = Str::random(3) . $fileName;
         }
 
@@ -106,18 +156,19 @@ class ImageService
         }
 
         try {
-            $storage->put($fullPath, $imageData, ['visibility' => 'public']);
+            $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
         } catch (Exception $e) {
-            \Log::error('Error when attempting image upload:' . $e->getMessage());
+            Log::error('Error when attempting image upload:' . $e->getMessage());
+
             throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $fullPath]));
         }
 
         $imageDetails = [
-            'name' => $imageName,
-            'path' => $fullPath,
-            'url' => $this->getPublicUrl($fullPath),
-            'type' => $type,
-            'uploaded_to' => $uploadedTo
+            'name'        => $imageName,
+            'path'        => $fullPath,
+            'url'         => $this->getPublicUrl($fullPath),
+            'type'        => $type,
+            'uploaded_to' => $uploadedTo,
         ];
 
         if (user()->id !== 0) {
@@ -128,9 +179,29 @@ class ImageService
 
         $image = $this->image->newInstance();
         $image->forceFill($imageDetails)->save();
+
         return $image;
     }
 
+    /**
+     * Save image data for the given path in the public space, if possible,
+     * for the provided storage mechanism.
+     */
+    protected function saveImageDataInPublicSpace(Storage $storage, string $path, string $data)
+    {
+        $storage->put($path, $data);
+
+        // Set visibility when a non-AWS-s3, s3-like storage option is in use.
+        // Done since this call can break s3-like services but desired for other image stores.
+        // Attempting to set ACL during above put request requires different permissions
+        // hence would technically be a breaking change for actual s3 usage.
+        $usingS3 = strtolower(config('filesystems.images')) === 's3';
+        $usingS3Like = $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
+        if (!$usingS3Like) {
+            $storage->setVisibility($path, 'public');
+        }
+    }
+
     /**
      * Clean up an image file name to be both URL and storage safe.
      */
@@ -157,20 +228,32 @@ class ImageService
         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
     }
 
+    /**
+     * Check if the given image and image data is apng.
+     */
+    protected function isApngData(Image $image, string &$imageData): bool
+    {
+        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
+        if (!$isPng) {
+            return false;
+        }
+
+        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+
+        return strpos($initialHeader, 'acTL') !== false;
+    }
+
     /**
      * Get the thumbnail for an image.
      * If $keepRatio is true only the width will be used.
      * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
-     * @param Image $image
-     * @param int $width
-     * @param int $height
-     * @param bool $keepRatio
-     * @return string
+     *
      * @throws Exception
-     * @throws ImageUploadException
+     * @throws InvalidArgumentException
      */
-    public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
+    public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
     {
+        // Do not resize GIF images where we're not cropping
         if ($keepRatio && $this->isGif($image)) {
             return $this->getPublicUrl($image->path);
         }
@@ -179,42 +262,50 @@ class ImageService
         $imagePath = $image->path;
         $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
 
-        if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
-            return $this->getPublicUrl($thumbFilePath);
+        $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
+
+        // Return path if in cache
+        $cachedThumbPath = $this->cache->get($thumbCacheKey);
+        if ($cachedThumbPath) {
+            return $this->getPublicUrl($cachedThumbPath);
         }
 
-        $storage = $this->getStorage($image->type);
-        if ($storage->exists($thumbFilePath)) {
+        // If thumbnail has already been generated, serve that and cache path
+        $storage = $this->getStorageDisk($image->type);
+        if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
+            $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
+
             return $this->getPublicUrl($thumbFilePath);
         }
 
-        $thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
+        $imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
 
-        $storage->put($thumbFilePath, $thumbData, ['visibility' => 'public']);
-        $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
+        // Do not resize apng images where we're not cropping
+        if ($keepRatio && $this->isApngData($image, $imageData)) {
+            $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
 
+            return $this->getPublicUrl($image->path);
+        }
+
+        // If not in cache and thumbnail does not exist, generate thumb and cache path
+        $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
+        $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
+        $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
 
         return $this->getPublicUrl($thumbFilePath);
     }
 
     /**
-     * Resize image data.
-     * @param string $imageData
-     * @param int $width
-     * @param int $height
-     * @param bool $keepRatio
-     * @return string
+     * Resize the image of given data to the specified size, and return the new image data.
+     *
      * @throws ImageUploadException
      */
-    protected function resizeImage(string $imageData, $width = 220, $height = null, bool $keepRatio = true)
+    protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
     {
         try {
             $thumb = $this->imageTool->make($imageData);
-        } catch (Exception $e) {
-            if ($e instanceof ErrorException || $e instanceof NotSupportedException) {
-                throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
-            }
-            throw $e;
+        } catch (ErrorException|NotSupportedException $e) {
+            throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
         }
 
         if ($keepRatio) {
@@ -226,7 +317,7 @@ class ImageService
             $thumb->fit($width, $height);
         }
 
-        $thumbData = (string)$thumb->encode();
+        $thumbData = (string) $thumb->encode();
 
         // Use original image data if we're keeping the ratio
         // and the resizing does not save any space.
@@ -239,22 +330,24 @@ class ImageService
 
     /**
      * Get the raw data content from an image.
+     *
      * @throws FileNotFoundException
      */
     public function getImageData(Image $image): string
     {
-        $imagePath = $image->path;
-        $storage = $this->getStorage();
-        return $storage->get($imagePath);
+        $storage = $this->getStorageDisk();
+
+        return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
     }
 
     /**
      * Destroy an image along with its revisions, thumbnails and remaining folders.
+     *
      * @throws Exception
      */
     public function destroy(Image $image)
     {
-        $this->destroyImagesFromPath($image->path);
+        $this->destroyImagesFromPath($image->path, $image->type);
         $image->delete();
     }
 
@@ -262,9 +355,10 @@ class ImageService
      * Destroys an image at the given path.
      * Searches for image thumbnails in addition to main provided path.
      */
-    protected function destroyImagesFromPath(string $path): bool
+    protected function destroyImagesFromPath(string $path, string $imageType): bool
     {
-        $storage = $this->getStorage();
+        $path = $this->adjustPathForStorageDisk($path, $imageType);
+        $storage = $this->getStorageDisk($imageType);
 
         $imageFolder = dirname($path);
         $imageFileName = basename($path);
@@ -288,13 +382,14 @@ class ImageService
     }
 
     /**
-     * Check whether or not a folder is empty.
+     * Check whether a folder is empty.
      */
-    protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
+    protected function isFolderEmpty(Storage $storage, string $path): bool
     {
         $files = $storage->files($path);
         $folders = $storage->directories($path);
-        return (count($files) === 0 && count($folders) === 0);
+
+        return count($files) === 0 && count($folders) === 0;
     }
 
     /**
@@ -330,14 +425,16 @@ class ImageService
                     }
                 }
             });
+
         return $deletedPaths;
     }
 
     /**
-     * Convert a image URI to a Base64 encoded string.
+     * Convert an image URI to a Base64 encoded string.
      * Attempts to convert the URL to a system storage url then
      * fetch the data from the disk or storage location.
      * Returns null if the image data cannot be fetched from storage.
+     *
      * @throws FileNotFoundException
      */
     public function imageUriToBase64(string $uri): ?string
@@ -347,7 +444,8 @@ class ImageService
             return null;
         }
 
-        $storage = $this->getStorage();
+        $storagePath = $this->adjustPathForStorageDisk($storagePath);
+        $storage = $this->getStorageDisk();
         $imageData = null;
         if ($storage->exists($storagePath)) {
             $imageData = $storage->get($storagePath);
@@ -365,6 +463,44 @@ class ImageService
         return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
     }
 
+    /**
+     * Check if the given path exists in the local secure image system.
+     * Returns false if local_secure is not in use.
+     */
+    public function pathExistsInLocalSecure(string $imagePath): bool
+    {
+        /** @var FilesystemAdapter $disk */
+        $disk = $this->getStorageDisk('gallery');
+
+        // Check local_secure is active
+        return $this->usingSecureImages()
+            && $disk instanceof FilesystemAdapter
+            // Check the image file exists
+            && $disk->exists($imagePath)
+            // Check the file is likely an image file
+            && strpos($disk->getMimetype($imagePath), 'image/') === 0;
+    }
+
+    /**
+     * For the given path, if existing, provide a response that will stream the image contents.
+     */
+    public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
+    {
+        $disk = $this->getStorageDisk($imageType);
+
+        return $disk->response($path);
+    }
+
+    /**
+     * Check if the given image extension is supported by BookStack.
+     * The extension must not be altered in this function. This check should provide a guarantee
+     * that the provided extension is safe to use for the image to be saved.
+     */
+    public static function isExtensionSupported(string $extension): bool
+    {
+        return in_array($extension, static::$supportedExtensions);
+    }
+
     /**
      * Get a storage path for the given image URL.
      * Ensures the path will start with "uploads/images".
@@ -380,6 +516,7 @@ class ImageService
             if (strpos(strtolower($url), 'uploads/images') === 0) {
                 return trim($url, '/');
             }
+
             return null;
         }
 
@@ -405,7 +542,7 @@ class ImageService
      */
     private function getPublicUrl(string $filePath): string
     {
-        if ($this->storageUrl === null) {
+        if (is_null($this->storageUrl)) {
             $storageUrl = config('filesystems.url');
 
             // Get the standard public s3 url if s3 is set as storage type
@@ -419,10 +556,12 @@ class ImageService
                     $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
                 }
             }
+
             $this->storageUrl = $storageUrl;
         }
 
         $basePath = ($this->storageUrl == false) ? url('/') : $this->storageUrl;
+
         return rtrim($basePath, '/') . $filePath;
     }
 }
index f1509bbb8079b163f9ce15b035adf430622a8fc9..f5b085a35d5cf802a45de6710034421f4c653716 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Auth\User;
 use BookStack\Exceptions\HttpFetchException;
@@ -26,6 +28,7 @@ class UserAvatars
         }
 
         try {
+            $this->destroyAllForUser($user);
             $avatar = $this->saveAvatarImage($user);
             $user->avatar()->associate($avatar);
             $user->save();
@@ -34,8 +37,38 @@ class UserAvatars
         }
     }
 
+    /**
+     * Assign a new avatar image to the given user using the given image data.
+     */
+    public function assignToUserFromExistingData(User $user, string $imageData, string $extension): void
+    {
+        try {
+            $this->destroyAllForUser($user);
+            $avatar = $this->createAvatarImageFromData($user, $imageData, $extension);
+            $user->avatar()->associate($avatar);
+            $user->save();
+        } catch (Exception $e) {
+            Log::error('Failed to save user avatar image');
+        }
+    }
+
+    /**
+     * Destroy all user avatars uploaded to the given user.
+     */
+    public function destroyAllForUser(User $user)
+    {
+        $profileImages = Image::query()->where('type', '=', 'user')
+            ->where('uploaded_to', '=', $user->id)
+            ->get();
+
+        foreach ($profileImages as $image) {
+            $this->imageService->destroy($image);
+        }
+    }
+
     /**
      * Save an avatar image from an external service.
+     *
      * @throws Exception
      */
     protected function saveAvatarImage(User $user, int $size = 500): Image
@@ -44,15 +77,24 @@ class UserAvatars
         $email = strtolower(trim($user->email));
 
         $replacements = [
-            '${hash}' => md5($email),
-            '${size}' => $size,
+            '${hash}'  => md5($email),
+            '${size}'  => $size,
             '${email}' => urlencode($email),
         ];
 
         $userAvatarUrl = strtr($avatarUrl, $replacements);
-        $imageName = str_replace(' ', '-', $user->id . '-avatar.png');
         $imageData = $this->getAvatarImageData($userAvatarUrl);
 
+        return $this->createAvatarImageFromData($user, $imageData, 'png');
+    }
+
+    /**
+     * Creates a new image instance and saves it in the system as a new user avatar image.
+     */
+    protected function createAvatarImageFromData(User $user, string $imageData, string $extension): Image
+    {
+        $imageName = str_replace(' ', '-', $user->id . '-avatar.' . $extension);
+
         $image = $this->imageService->saveNew($imageName, $imageData, 'user', $user->id);
         $image->created_by = $user->id;
         $image->updated_by = $user->id;
@@ -63,6 +105,7 @@ class UserAvatars
 
     /**
      * Gets an image from url and returns it as a string of image data.
+     *
      * @throws Exception
      */
     protected function getAvatarImageData(string $url): string
@@ -72,6 +115,7 @@ class UserAvatars
         } catch (HttpFetchException $exception) {
             throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
         }
+
         return $imageData;
     }
 
@@ -81,6 +125,7 @@ class UserAvatars
     protected function avatarFetchEnabled(): bool
     {
         $fetchUrl = $this->getAvatarUrl();
+
         return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
     }
 
diff --git a/app/Util/CspService.php b/app/Util/CspService.php
new file mode 100644 (file)
index 0000000..812e1a4
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace BookStack\Util;
+
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\Response;
+
+class CspService
+{
+    /** @var string */
+    protected $nonce;
+
+    public function __construct(string $nonce = '')
+    {
+        $this->nonce = $nonce ?: Str::random(24);
+    }
+
+    /**
+     * Get the nonce value for CSP.
+     */
+    public function getNonce(): string
+    {
+        return $this->nonce;
+    }
+
+    /**
+     * Sets CSP 'script-src' headers to restrict the forms of script that can
+     * run on the page.
+     */
+    public function setScriptSrc(Response $response)
+    {
+        if (config('app.allow_content_scripts')) {
+            return;
+        }
+
+        $parts = [
+            'http:',
+            'https:',
+            '\'nonce-' . $this->nonce . '\'',
+            '\'strict-dynamic\'',
+        ];
+
+        $value = 'script-src ' . implode(' ', $parts);
+        $response->headers->set('Content-Security-Policy', $value, false);
+    }
+
+    /**
+     * Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
+     * iframed within. Also adjusts the cookie samesite options so that cookies will
+     * operate in the third-party context.
+     */
+    public function setFrameAncestors(Response $response)
+    {
+        $iframeHosts = $this->getAllowedIframeHosts();
+        array_unshift($iframeHosts, "'self'");
+        $cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
+        $response->headers->set('Content-Security-Policy', $cspValue, false);
+    }
+
+    /**
+     * Check if the user has configured some allowed iframe hosts.
+     */
+    public function allowedIFrameHostsConfigured(): bool
+    {
+        return count($this->getAllowedIframeHosts()) > 0;
+    }
+
+    /**
+     * Sets CSP 'object-src' headers to restrict the types of dynamic content
+     * that can be embedded on the page.
+     */
+    public function setObjectSrc(Response $response)
+    {
+        if (config('app.allow_content_scripts')) {
+            return;
+        }
+
+        $response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
+    }
+
+    /**
+     * Sets CSP 'base-uri' headers to restrict what base tags can be set on
+     * the page to prevent manipulation of relative links.
+     */
+    public function setBaseUri(Response $response)
+    {
+        $response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
+    }
+
+    protected function getAllowedIframeHosts(): array
+    {
+        $hosts = config('app.iframe_hosts', '');
+
+        return array_filter(explode(' ', $hosts));
+    }
+}
index cec927a3cef7d4ee5651ed226401bfcc3ccca4c9..08dde7048320f93b8f7a98fdfb536b4480ef7b73 100644 (file)
@@ -1,14 +1,17 @@
-<?php namespace BookStack\Util;
+<?php
 
+namespace BookStack\Util;
+
+use DOMAttr;
 use DOMDocument;
-use DOMNode;
+use DOMElement;
 use DOMNodeList;
 use DOMXPath;
 
 class HtmlContentFilter
 {
     /**
-     * Remove all of the script elements from the given HTML.
+     * Remove all the script elements from the given HTML.
      */
     public static function removeScripts(string $html): string
     {
@@ -16,6 +19,7 @@ class HtmlContentFilter
             return $html;
         }
 
+        $html = '<body>' . $html . '</body>';
         libxml_use_internal_errors(true);
         $doc = new DOMDocument();
         $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
@@ -26,28 +30,29 @@ class HtmlContentFilter
         static::removeNodes($scriptElems);
 
         // Remove clickable links to JavaScript URI
-        $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
+        $badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
         static::removeNodes($badLinks);
 
         // Remove forms with calls to JavaScript URI
-        $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
+        $badForms = $xPath->query('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
         static::removeNodes($badForms);
 
         // Remove meta tag to prevent external redirects
-        $metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
+        $metaTags = $xPath->query('//meta[' . static::xpathContains('@content', 'url') . ']');
         static::removeNodes($metaTags);
 
         // Remove data or JavaScript iFrames
-        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+        $badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
         static::removeNodes($badIframes);
 
+        // Remove elements with a xlink:href attribute
+        // Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
+        $xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');
+        static::removeAttributes($xlinkHrefAttributes);
+
         // Remove 'on*' attributes
         $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
-        foreach ($onAttributes as $attr) {
-            /** @var \DOMAttr $attr*/
-            $attrName = $attr->nodeName;
-            $attr->parentNode->removeAttribute($attrName);
-        }
+        static::removeAttributes($onAttributes);
 
         $html = '';
         $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
@@ -59,13 +64,38 @@ class HtmlContentFilter
     }
 
     /**
-     * Removed all of the given DOMNodes.
+     * Create a xpath contains statement with a translation automatically built within
+     * to affectively search in a cases-insensitive manner.
+     */
+    protected static function xpathContains(string $property, string $value): string
+    {
+        $value = strtolower($value);
+        $upperVal = strtoupper($value);
+
+        return 'contains(translate(' . $property . ', \'' . $upperVal . '\', \'' . $value . '\'), \'' . $value . '\')';
+    }
+
+    /**
+     * Remove all the given DOMNodes.
      */
-    static protected function removeNodes(DOMNodeList $nodes): void
+    protected static function removeNodes(DOMNodeList $nodes): void
     {
         foreach ($nodes as $node) {
             $node->parentNode->removeChild($node);
         }
     }
 
-}
\ No newline at end of file
+    /**
+     * Remove all the given attribute nodes.
+     */
+    protected static function removeAttributes(DOMNodeList $attrs): void
+    {
+        /** @var DOMAttr $attr */
+        foreach ($attrs as $attr) {
+            $attrName = $attr->nodeName;
+            /** @var DOMElement $parentNode */
+            $parentNode = $attr->parentNode;
+            $parentNode->removeAttribute($attrName);
+        }
+    }
+}
diff --git a/app/Util/HtmlNonceApplicator.php b/app/Util/HtmlNonceApplicator.php
new file mode 100644 (file)
index 0000000..0729857
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace BookStack\Util;
+
+use DOMDocument;
+use DOMElement;
+use DOMNodeList;
+use DOMXPath;
+
+class HtmlNonceApplicator
+{
+    protected static $placeholder = '[CSP_NONCE_VALUE]';
+
+    /**
+     * Prepare the given HTML content with nonce attributes including a placeholder
+     * value which we can target later.
+     */
+    public static function prepare(string $html): string
+    {
+        if (empty($html)) {
+            return $html;
+        }
+
+        $html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML($html, LIBXML_SCHEMA_CREATE);
+        $xPath = new DOMXPath($doc);
+
+        // Apply to scripts
+        $scriptElems = $xPath->query('//script');
+        static::addNonceAttributes($scriptElems, static::$placeholder);
+
+        // Apply to styles
+        $styleElems = $xPath->query('//style');
+        static::addNonceAttributes($styleElems, static::$placeholder);
+
+        $returnHtml = '';
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        foreach ($topElems as $child) {
+            $content = $doc->saveHTML($child);
+            $returnHtml .= $content;
+        }
+
+        return $returnHtml;
+    }
+
+    /**
+     * Apply the give nonce value to the given prepared HTML.
+     */
+    public static function apply(string $html, string $nonce): string
+    {
+        return str_replace(static::$placeholder, $nonce, $html);
+    }
+
+    protected static function addNonceAttributes(DOMNodeList $nodes, string $attrValue): void
+    {
+        /** @var DOMElement $node */
+        foreach ($nodes as $node) {
+            $node->setAttribute('nonce', $attrValue);
+        }
+    }
+}
diff --git a/app/Util/WebSafeMimeSniffer.php b/app/Util/WebSafeMimeSniffer.php
new file mode 100644 (file)
index 0000000..ea58e58
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+namespace BookStack\Util;
+
+use finfo;
+
+/**
+ * Helper class to sniff out the mime-type of content resulting in
+ * a mime-type that's relatively safe to serve to a browser.
+ */
+class WebSafeMimeSniffer
+{
+    /**
+     * @var string[]
+     */
+    protected $safeMimes = [
+        'application/json',
+        'application/octet-stream',
+        'application/pdf',
+        'image/apng',
+        'image/bmp',
+        'image/jpeg',
+        'image/png',
+        'image/gif',
+        'image/webp',
+        'image/avif',
+        'image/heic',
+        'text/css',
+        'text/csv',
+        'text/javascript',
+        'text/json',
+        'text/plain',
+        'video/x-msvideo',
+        'video/mp4',
+        'video/mpeg',
+        'video/ogg',
+        'video/webm',
+        'video/vp9',
+        'video/h264',
+        'video/av1',
+    ];
+
+    /**
+     * Sniff the mime-type from the given file content while running the result
+     * through an allow-list to ensure a web-safe result.
+     * Takes the content as a reference since the value may be quite large.
+     */
+    public function sniff(string &$content): string
+    {
+        $fInfo = new finfo(FILEINFO_MIME_TYPE);
+        $mime = $fInfo->buffer($content) ?: 'application/octet-stream';
+
+        if (in_array($mime, $this->safeMimes)) {
+            return $mime;
+        }
+
+        [$category] = explode('/', $mime, 2);
+        if ($category === 'text') {
+            return 'text/plain';
+        }
+
+        return 'application/octet-stream';
+    }
+}
index c1d72b91da4fb7f5bb3efba3a2de46490ec3dce9..9edc22c403d9b1a7859baaec7983a8ad14e898a4 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Settings\SettingService;
 
 /**
  * Get the path to a versioned file.
+ *
  * @throws Exception
  */
 function versioned_asset(string $file = ''): string
@@ -24,6 +25,7 @@ function versioned_asset(string $file = ''): string
     }
 
     $path = $file . '?version=' . urlencode($version) . $additional;
+
     return url($path);
 }
 
@@ -64,6 +66,7 @@ function userCan(string $permission, Model $ownable = null): bool
 
     // Check permission on ownable item
     $permissionService = app(PermissionService::class);
+
     return $permissionService->checkOwnableUserAccess($ownable, $permission);
 }
 
@@ -74,11 +77,13 @@ function userCan(string $permission, Model $ownable = null): bool
 function userCanOnAny(string $permission, string $entityClass = null): bool
 {
     $permissionService = app(PermissionService::class);
+
     return $permissionService->checkUserHasPermissionOnAnything($permission, $entityClass);
 }
 
 /**
  * Helper to access system settings.
+ *
  * @return mixed|SettingService
  */
 function setting(string $key = null, $default = null)
@@ -94,16 +99,18 @@ function setting(string $key = null, $default = null)
 
 /**
  * Get a path to a theme resource.
+ * Returns null if a theme is not configured and
+ * therefore a full path is not available for use.
  */
-function theme_path(string $path = ''): string
+function theme_path(string $path = ''): ?string
 {
     $theme = config('view.theme');
 
     if (!$theme) {
-        return '';
+        return null;
     }
 
-    return base_path('themes/' . $theme .($path ? DIRECTORY_SEPARATOR.$path : $path));
+    return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
 }
 
 /**
@@ -122,7 +129,7 @@ function icon(string $name, array $attrs = []): string
     ], $attrs);
     $attrString = ' ';
     foreach ($attrs as $attrName => $attr) {
-        $attrString .=  $attrName . '="' . $attr . '" ';
+        $attrString .= $attrName . '="' . $attr . '" ';
     }
 
     $iconPath = resource_path('icons/' . $name . '.svg');
@@ -130,11 +137,12 @@ function icon(string $name, array $attrs = []): string
 
     if ($themeIconPath && file_exists($themeIconPath)) {
         $iconPath = $themeIconPath;
-    } else if (!file_exists($iconPath)) {
+    } elseif (!file_exists($iconPath)) {
         return '';
     }
 
     $fileContents = file_get_contents($iconPath);
+
     return  str_replace('<svg', '<svg' . $attrString, $fileContents);
 }
 
diff --git a/bootstrap/phpstan.php b/bootstrap/phpstan.php
new file mode 100644 (file)
index 0000000..1505fdb
--- /dev/null
@@ -0,0 +1,6 @@
+<?php
+
+// Overwrite configuration that can interfere with the phpstan/larastan scanning.
+config()->set([
+    'filesystems.default' => 'local',
+]);
index 3e604b8fdfb29dd13cef6be681971ecdad0a9a9b..d4bd02d74604799f2472fe17b4a346fdd1dbf9bb 100644 (file)
@@ -1,58 +1,66 @@
 {
     "name": "bookstackapp/bookstack",
     "description": "BookStack documentation platform",
-    "keywords": ["BookStack", "Documentation"],
+    "keywords": [
+        "BookStack",
+        "Documentation"
+    ],
     "license": "MIT",
     "type": "project",
     "require": {
         "php": "^7.3|^8.0",
         "ext-curl": "*",
         "ext-dom": "*",
+        "ext-fileinfo": "*",
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
         "ext-xml": "*",
+        "bacon/bacon-qr-code": "^2.0",
         "barryvdh/laravel-dompdf": "^0.9.0",
         "barryvdh/laravel-snappy": "^0.4.8",
-        "doctrine/dbal": "^2.12.1",
-        "facade/ignition": "^1.16.4",
-        "fideloper/proxy": "^4.4.1",
-        "intervention/image": "^2.5.1",
-        "laravel/framework": "^6.20.16",
-        "laravel/socialite": "^5.1",
-        "league/commonmark": "^1.5",
+        "doctrine/dbal": "^3.1",
+        "filp/whoops": "^2.14",
+        "guzzlehttp/guzzle": "^7.4",
+        "intervention/image": "^2.7",
+        "laravel/framework": "^8.68",
+        "laravel/socialite": "^5.2",
+        "laravel/tinker": "^2.6",
+        "laravel/ui": "^3.3",
+        "league/commonmark": "^1.6",
         "league/flysystem-aws-s3-v3": "^1.0.29",
-        "nunomaduro/collision": "^3.1",
+        "league/html-to-markdown": "^5.0.0",
+        "league/oauth2-client": "^2.6",
         "onelogin/php-saml": "^4.0",
-        "predis/predis": "^1.1.6",
+        "phpseclib/phpseclib": "~3.0",
+        "pragmarx/google2fa": "^8.0",
+        "predis/predis": "^1.1",
         "socialiteproviders/discord": "^4.1",
         "socialiteproviders/gitlab": "^4.1",
-        "socialiteproviders/microsoft-azure": "^4.1",
+        "socialiteproviders/microsoft-azure": "^5.0.1",
         "socialiteproviders/okta": "^4.1",
         "socialiteproviders/slack": "^4.1",
         "socialiteproviders/twitch": "^5.3",
-        "ssddanbrown/htmldiff": "^v1.0.1"
+        "ssddanbrown/htmldiff": "^1.0.2"
     },
     "require-dev": {
-        "barryvdh/laravel-debugbar": "^3.5.1",
-        "barryvdh/laravel-ide-helper": "^2.8.2",
-        "fakerphp/faker": "^1.13.0",
-        "laravel/browser-kit-testing": "^5.2",
-        "mockery/mockery": "^1.3.3",
-        "phpunit/phpunit": "^9.5.3",
-        "squizlabs/php_codesniffer": "^3.5.8"
+        "fakerphp/faker": "^1.16",
+        "itsgoingd/clockwork": "^5.1",
+        "mockery/mockery": "^1.4",
+        "nunomaduro/collision": "^5.10",
+        "nunomaduro/larastan": "^1.0",
+        "phpunit/phpunit": "^9.5",
+        "symfony/dom-crawler": "^5.3"
     },
     "autoload": {
-        "classmap": [
-            "database/seeds",
-            "database/factories"
-        ],
         "psr-4": {
-            "BookStack\\": "app/"
+            "BookStack\\": "app/",
+            "Database\\Factories\\": "database/factories/",
+            "Database\\Seeders\\": "database/seeders/"
         },
-               "files": [
-                       "app/helpers.php"
-               ]
+        "files": [
+            "app/helpers.php"
+        ]
     },
     "autoload-dev": {
         "psr-4": {
         }
     },
     "scripts": {
+        "post-autoload-dump": [
+            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
+            "@php artisan package:discover --ansi"
+        ],
         "post-root-package-install": [
             "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
         ],
             "@php artisan cache:clear",
             "@php artisan view:clear"
         ],
-        "post-autoload-dump": [
-            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
-            "@php artisan package:discover --ansi"
-        ],
         "refresh-test-database": [
             "@php artisan migrate:refresh --database=mysql_testing",
             "@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
index 7710274c10f00a5a66dcab5309843fc4cfbe57bc..9517c9e2bd2c27aa246acf28243204c3460b02c3 100644 (file)
@@ -4,29 +4,80 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "b26d29958d84c91b164a8234d1a7e9e9",
+    "content-hash": "2eead2f6889d5a411e5db8a456c43ddc",
     "packages": [
+        {
+            "name": "aws/aws-crt-php",
+            "version": "v1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/awslabs/aws-crt-php.git",
+                "reference": "3942776a8c99209908ee0b287746263725685732"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/3942776a8c99209908ee0b287746263725685732",
+                "reference": "3942776a8c99209908ee0b287746263725685732",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.5"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35|^5.4.3"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "AWS SDK Common Runtime Team",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "AWS Common Runtime for PHP",
+            "homepage": "http://aws.amazon.com/sdkforphp",
+            "keywords": [
+                "amazon",
+                "aws",
+                "crt",
+                "sdk"
+            ],
+            "support": {
+                "issues": "https://github.com/awslabs/aws-crt-php/issues",
+                "source": "https://github.com/awslabs/aws-crt-php/tree/v1.0.2"
+            },
+            "time": "2021-09-03T22:57:30+00:00"
+        },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.180.0",
+            "version": "3.209.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "761d06d3d320bd1a0114f9d937eccd1613e1913b"
+                "reference": "9f9591bff3dc0b2bc5400eb81e2e22228f2e4c95"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/761d06d3d320bd1a0114f9d937eccd1613e1913b",
-                "reference": "761d06d3d320bd1a0114f9d937eccd1613e1913b",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9f9591bff3dc0b2bc5400eb81e2e22228f2e4c95",
+                "reference": "9f9591bff3dc0b2bc5400eb81e2e22228f2e4c95",
                 "shasum": ""
             },
             "require": {
+                "aws/aws-crt-php": "^1.0.2",
                 "ext-json": "*",
                 "ext-pcre": "*",
                 "ext-simplexml": "*",
                 "guzzlehttp/guzzle": "^5.3.3|^6.2.1|^7.0",
                 "guzzlehttp/promises": "^1.4.0",
-                "guzzlehttp/psr7": "^1.7.0",
+                "guzzlehttp/psr7": "^1.7.0|^2.0",
                 "mtdowling/jmespath.php": "^2.6",
                 "php": ">=5.5"
             },
             "support": {
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.180.0"
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.209.11"
+            },
+            "time": "2022-01-24T19:15:49+00:00"
+        },
+        {
+            "name": "bacon/bacon-qr-code",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Bacon/BaconQrCode.git",
+                "reference": "f73543ac4e1def05f1a70bcd1525c8a157a1ad09"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/f73543ac4e1def05f1a70bcd1525c8a157a1ad09",
+                "reference": "f73543ac4e1def05f1a70bcd1525c8a157a1ad09",
+                "shasum": ""
+            },
+            "require": {
+                "dasprid/enum": "^1.0.3",
+                "ext-iconv": "*",
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "phly/keep-a-changelog": "^1.4",
+                "phpunit/phpunit": "^7 | ^8 | ^9",
+                "squizlabs/php_codesniffer": "^3.4"
+            },
+            "suggest": {
+                "ext-imagick": "to generate QR code images"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "BaconQrCode\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ben Scholzen 'DASPRiD'",
+                    "email": "[email protected]",
+                    "homepage": "https://dasprids.de/",
+                    "role": "Developer"
+                }
+            ],
+            "description": "BaconQrCode is a QR code generator for PHP.",
+            "homepage": "https://github.com/Bacon/BaconQrCode",
+            "support": {
+                "issues": "https://github.com/Bacon/BaconQrCode/issues",
+                "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.4"
             },
-            "time": "2021-05-03T20:41:22+00:00"
+            "time": "2021-06-18T13:26:35+00:00"
         },
         {
             "name": "barryvdh/laravel-dompdf",
             },
             "time": "2020-09-07T12:33:10+00:00"
         },
+        {
+            "name": "brick/math",
+            "version": "0.9.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/brick/math.git",
+                "reference": "ca57d18f028f84f777b2168cd1911b0dee2343ae"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/brick/math/zipball/ca57d18f028f84f777b2168cd1911b0dee2343ae",
+                "reference": "ca57d18f028f84f777b2168cd1911b0dee2343ae",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.2",
+                "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.0",
+                "vimeo/psalm": "4.9.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Brick\\Math\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Arbitrary-precision arithmetic library",
+            "keywords": [
+                "Arbitrary-precision",
+                "BigInteger",
+                "BigRational",
+                "arithmetic",
+                "bigdecimal",
+                "bignum",
+                "brick",
+                "math"
+            ],
+            "support": {
+                "issues": "https://github.com/brick/math/issues",
+                "source": "https://github.com/brick/math/tree/0.9.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/BenMorel",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/brick/math",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-08-15T20:50:18+00:00"
+        },
+        {
+            "name": "dasprid/enum",
+            "version": "1.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/DASPRiD/Enum.git",
+                "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/5abf82f213618696dda8e3bf6f64dd042d8542b2",
+                "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2",
+                "shasum": ""
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^7 | ^8 | ^9",
+                "squizlabs/php_codesniffer": "^3.4"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "DASPRiD\\Enum\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ben Scholzen 'DASPRiD'",
+                    "email": "[email protected]",
+                    "homepage": "https://dasprids.de/",
+                    "role": "Developer"
+                }
+            ],
+            "description": "PHP 7.1 enum implementation",
+            "keywords": [
+                "enum",
+                "map"
+            ],
+            "support": {
+                "issues": "https://github.com/DASPRiD/Enum/issues",
+                "source": "https://github.com/DASPRiD/Enum/tree/1.0.3"
+            },
+            "time": "2020-10-02T16:03:48+00:00"
+        },
         {
             "name": "doctrine/cache",
-            "version": "1.11.0",
+            "version": "2.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/cache.git",
-                "reference": "a9c1b59eba5a08ca2770a76eddb88922f504e8e0"
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/cache/zipball/a9c1b59eba5a08ca2770a76eddb88922f504e8e0",
-                "reference": "a9c1b59eba5a08ca2770a76eddb88922f504e8e0",
+                "url": "https://api.github.com/repos/doctrine/cache/zipball/331b4d5dbaeab3827976273e9356b3b453c300ce",
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce",
                 "shasum": ""
             },
             "require": {
                 "php": "~7.1 || ^8.0"
             },
             "conflict": {
-                "doctrine/common": ">2.2,<2.4",
-                "psr/cache": ">=3"
+                "doctrine/common": ">2.2,<2.4"
             },
             "require-dev": {
                 "alcaeus/mongo-php-adapter": "^1.1",
                 "mongodb/mongodb": "^1.1",
                 "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
                 "predis/predis": "~1.0",
-                "psr/cache": "^1.0 || ^2.0",
-                "symfony/cache": "^4.4 || ^5.2"
+                "psr/cache": "^1.0 || ^2.0 || ^3.0",
+                "symfony/cache": "^4.4 || ^5.2 || ^6.0@dev",
+                "symfony/var-exporter": "^4.4 || ^5.2 || ^6.0@dev"
             },
             "suggest": {
                 "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver"
             ],
             "support": {
                 "issues": "https://github.com/doctrine/cache/issues",
-                "source": "https://github.com/doctrine/cache/tree/1.11.0"
+                "source": "https://github.com/doctrine/cache/tree/2.1.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-13T14:46:17+00:00"
+            "time": "2021-07-17T14:49:29+00:00"
         },
         {
             "name": "doctrine/dbal",
-            "version": "2.13.1",
+            "version": "3.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "c800380457948e65bbd30ba92cc17cda108bf8c9"
+                "reference": "a4b37db6f186b6843474189b424aed6a7cc5de4b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/c800380457948e65bbd30ba92cc17cda108bf8c9",
-                "reference": "c800380457948e65bbd30ba92cc17cda108bf8c9",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/a4b37db6f186b6843474189b424aed6a7cc5de4b",
+                "reference": "a4b37db6f186b6843474189b424aed6a7cc5de4b",
                 "shasum": ""
             },
             "require": {
-                "doctrine/cache": "^1.0",
+                "composer-runtime-api": "^2",
+                "doctrine/cache": "^1.11|^2.0",
                 "doctrine/deprecations": "^0.5.3",
                 "doctrine/event-manager": "^1.0",
-                "ext-pdo": "*",
-                "php": "^7.1 || ^8"
+                "php": "^7.3 || ^8.0",
+                "psr/cache": "^1|^2|^3",
+                "psr/log": "^1|^2|^3"
             },
             "require-dev": {
-                "doctrine/coding-standard": "8.2.0",
-                "jetbrains/phpstorm-stubs": "2020.2",
-                "phpstan/phpstan": "0.12.81",
-                "phpunit/phpunit": "^7.5.20|^8.5|9.5.0",
-                "squizlabs/php_codesniffer": "3.6.0",
-                "symfony/console": "^2.0.5|^3.0|^4.0|^5.0",
-                "vimeo/psalm": "4.6.4"
+                "doctrine/coding-standard": "9.0.0",
+                "jetbrains/phpstorm-stubs": "2021.1",
+                "phpstan/phpstan": "1.4.0",
+                "phpstan/phpstan-strict-rules": "^1.1",
+                "phpunit/phpunit": "9.5.11",
+                "psalm/plugin-phpunit": "0.16.1",
+                "squizlabs/php_codesniffer": "3.6.2",
+                "symfony/cache": "^5.2|^6.0",
+                "symfony/console": "^2.7|^3.0|^4.0|^5.0|^6.0",
+                "vimeo/psalm": "4.16.1"
             },
             "suggest": {
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
             "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Doctrine\\DBAL\\": "lib/Doctrine/DBAL"
+                    "Doctrine\\DBAL\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 "queryobject",
                 "sasql",
                 "sql",
-                "sqlanywhere",
                 "sqlite",
                 "sqlserver",
                 "sqlsrv"
             ],
             "support": {
                 "issues": "https://github.com/doctrine/dbal/issues",
-                "source": "https://github.com/doctrine/dbal/tree/2.13.1"
+                "source": "https://github.com/doctrine/dbal/tree/3.3.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-17T17:30:19+00:00"
+            "time": "2022-01-18T00:13:52+00:00"
         },
         {
             "name": "doctrine/deprecations",
         },
         {
             "name": "doctrine/inflector",
-            "version": "2.0.3",
+            "version": "2.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/inflector.git",
-                "reference": "9cf661f4eb38f7c881cac67c75ea9b00bf97b210"
+                "reference": "8b7ff3e4b7de6b2c84da85637b59fd2880ecaa89"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/inflector/zipball/9cf661f4eb38f7c881cac67c75ea9b00bf97b210",
-                "reference": "9cf661f4eb38f7c881cac67c75ea9b00bf97b210",
+                "url": "https://api.github.com/repos/doctrine/inflector/zipball/8b7ff3e4b7de6b2c84da85637b59fd2880ecaa89",
+                "reference": "8b7ff3e4b7de6b2c84da85637b59fd2880ecaa89",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.2 || ^8.0"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^7.0",
-                "phpstan/phpstan": "^0.11",
-                "phpstan/phpstan-phpunit": "^0.11",
-                "phpstan/phpstan-strict-rules": "^0.11",
-                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
+                "doctrine/coding-standard": "^8.2",
+                "phpstan/phpstan": "^0.12",
+                "phpstan/phpstan-phpunit": "^0.12",
+                "phpstan/phpstan-strict-rules": "^0.12",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "vimeo/psalm": "^4.10"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\Inflector\\": "lib/Doctrine/Inflector"
             ],
             "support": {
                 "issues": "https://github.com/doctrine/inflector/issues",
-                "source": "https://github.com/doctrine/inflector/tree/2.0.x"
+                "source": "https://github.com/doctrine/inflector/tree/2.0.4"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-29T15:13:26+00:00"
+            "time": "2021-10-22T20:16:43+00:00"
         },
         {
             "name": "doctrine/lexer",
-            "version": "1.2.1",
+            "version": "1.2.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/lexer.git",
-                "reference": "e864bbf5904cb8f5bb334f99209b48018522f042"
+                "reference": "9c50f840f257bbb941e6f4a0e94ccf5db5c3f76c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/lexer/zipball/e864bbf5904cb8f5bb334f99209b48018522f042",
-                "reference": "e864bbf5904cb8f5bb334f99209b48018522f042",
+                "url": "https://api.github.com/repos/doctrine/lexer/zipball/9c50f840f257bbb941e6f4a0e94ccf5db5c3f76c",
+                "reference": "9c50f840f257bbb941e6f4a0e94ccf5db5c3f76c",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2 || ^8.0"
+                "php": "^7.1 || ^8.0"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^6.0",
-                "phpstan/phpstan": "^0.11.8",
-                "phpunit/phpunit": "^8.2"
+                "doctrine/coding-standard": "^9.0",
+                "phpstan/phpstan": "1.3",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+                "vimeo/psalm": "^4.11"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.2.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer"
             ],
             "support": {
                 "issues": "https://github.com/doctrine/lexer/issues",
-                "source": "https://github.com/doctrine/lexer/tree/1.2.1"
+                "source": "https://github.com/doctrine/lexer/tree/1.2.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-25T17:44:05+00:00"
+            "time": "2022-01-12T08:27:12+00:00"
         },
         {
             "name": "dompdf/dompdf",
-            "version": "v1.0.2",
+            "version": "v1.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dompdf/dompdf.git",
-                "reference": "8768448244967a46d6e67b891d30878e0e15d25c"
+                "reference": "de4aad040737a89fae2129cdeb0f79c45513128d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/8768448244967a46d6e67b891d30878e0e15d25c",
-                "reference": "8768448244967a46d6e67b891d30878e0e15d25c",
+                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/de4aad040737a89fae2129cdeb0f79c45513128d",
+                "reference": "de4aad040737a89fae2129cdeb0f79c45513128d",
                 "shasum": ""
             },
             "require": {
                 "ext-zlib": "Needed for pdf stream compression"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-develop": "0.7-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Dompdf\\": "src/"
             "homepage": "https://github.com/dompdf/dompdf",
             "support": {
                 "issues": "https://github.com/dompdf/dompdf/issues",
-                "source": "https://github.com/dompdf/dompdf/tree/v1.0.2"
+                "source": "https://github.com/dompdf/dompdf/tree/v1.1.1"
             },
-            "time": "2021-01-08T14:18:52+00:00"
+            "time": "2021-11-24T00:45:04+00:00"
         },
         {
             "name": "dragonmantank/cron-expression",
-            "version": "v2.3.1",
+            "version": "v3.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dragonmantank/cron-expression.git",
-                "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2"
+                "reference": "be85b3f05b46c39bbc0d95f6c071ddff669510fa"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2",
-                "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2",
+                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/be85b3f05b46c39bbc0d95f6c071ddff669510fa",
+                "reference": "be85b3f05b46c39bbc0d95f6c071ddff669510fa",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.0|^8.0"
+                "php": "^7.2|^8.0",
+                "webmozart/assert": "^1.0"
+            },
+            "replace": {
+                "mtdowling/cron-expression": "^1.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0"
+                "phpstan/extension-installer": "^1.0",
+                "phpstan/phpstan": "^1.0",
+                "phpstan/phpstan-webmozart-assert": "^1.0",
+                "phpunit/phpunit": "^7.0|^8.0|^9.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.3-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Cron\\": "src/Cron/"
                 "MIT"
             ],
             "authors": [
-                {
-                    "name": "Michael Dowling",
-                    "email": "[email protected]",
-                    "homepage": "https://github.com/mtdowling"
-                },
                 {
                     "name": "Chris Tankersley",
                     "email": "[email protected]",
             ],
             "support": {
                 "issues": "https://github.com/dragonmantank/cron-expression/issues",
-                "source": "https://github.com/dragonmantank/cron-expression/tree/v2.3.1"
+                "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.1"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2020-10-13T00:52:37+00:00"
+            "time": "2022-01-18T15:43:28+00:00"
         },
         {
             "name": "egulias/email-validator",
             "time": "2020-12-29T14:50:06+00:00"
         },
         {
-            "name": "facade/flare-client-php",
-            "version": "1.8.0",
+            "name": "filp/whoops",
+            "version": "2.14.5",
             "source": {
                 "type": "git",
-                "url": "https://github.com/facade/flare-client-php.git",
-                "reference": "69742118c037f34ee1ef86dc605be4a105d9e984"
+                "url": "https://github.com/filp/whoops.git",
+                "reference": "a63e5e8f26ebbebf8ed3c5c691637325512eb0dc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/flare-client-php/zipball/69742118c037f34ee1ef86dc605be4a105d9e984",
-                "reference": "69742118c037f34ee1ef86dc605be4a105d9e984",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/a63e5e8f26ebbebf8ed3c5c691637325512eb0dc",
+                "reference": "a63e5e8f26ebbebf8ed3c5c691637325512eb0dc",
                 "shasum": ""
             },
             "require": {
-                "facade/ignition-contracts": "~1.0",
-                "illuminate/pipeline": "^5.5|^6.0|^7.0|^8.0",
-                "php": "^7.1|^8.0",
-                "symfony/http-foundation": "^3.3|^4.1|^5.0",
-                "symfony/mime": "^3.4|^4.0|^5.1",
-                "symfony/var-dumper": "^3.4|^4.0|^5.0"
+                "php": "^5.5.9 || ^7.0 || ^8.0",
+                "psr/log": "^1.0.1 || ^2.0 || ^3.0"
             },
             "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.14",
-                "phpunit/phpunit": "^7.5.16",
-                "spatie/phpunit-snapshot-assertions": "^2.0"
+                "mockery/mockery": "^0.9 || ^1.0",
+                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3",
+                "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0"
+            },
+            "suggest": {
+                "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
+                "whoops/soap": "Formats errors as SOAP responses"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0-dev"
+                    "dev-master": "2.7-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Facade\\FlareClient\\": "src"
-                },
-                "files": [
-                    "src/helpers.php"
-                ]
+                    "Whoops\\": "src/Whoops/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
-            "description": "Send PHP errors to Flare",
-            "homepage": "https://github.com/facade/flare-client-php",
+            "authors": [
+                {
+                    "name": "Filipe Dobreira",
+                    "homepage": "https://github.com/filp",
+                    "role": "Developer"
+                }
+            ],
+            "description": "php error handling for cool kids",
+            "homepage": "https://filp.github.io/whoops/",
             "keywords": [
+                "error",
                 "exception",
-                "facade",
-                "flare",
-                "reporting"
+                "handling",
+                "library",
+                "throwable",
+                "whoops"
             ],
             "support": {
-                "issues": "https://github.com/facade/flare-client-php/issues",
-                "source": "https://github.com/facade/flare-client-php/tree/1.8.0"
+                "issues": "https://github.com/filp/whoops/issues",
+                "source": "https://github.com/filp/whoops/tree/2.14.5"
             },
             "funding": [
                 {
-                    "url": "https://github.com/spatie",
+                    "url": "https://github.com/denis-sokolov",
                     "type": "github"
                 }
             ],
-            "time": "2021-04-30T11:11:50+00:00"
+            "time": "2022-01-07T12:00:00+00:00"
         },
         {
-            "name": "facade/ignition",
-            "version": "1.16.15",
+            "name": "graham-campbell/result-type",
+            "version": "v1.0.4",
             "source": {
                 "type": "git",
-                "url": "https://github.com/facade/ignition.git",
-                "reference": "b6aea4a99303d9d32afd486a285162a89af8a8a3"
+                "url": "https://github.com/GrahamCampbell/Result-Type.git",
+                "reference": "0690bde05318336c7221785f2a932467f98b64ca"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition/zipball/b6aea4a99303d9d32afd486a285162a89af8a8a3",
-                "reference": "b6aea4a99303d9d32afd486a285162a89af8a8a3",
+                "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/0690bde05318336c7221785f2a932467f98b64ca",
+                "reference": "0690bde05318336c7221785f2a932467f98b64ca",
                 "shasum": ""
             },
             "require": {
-                "ext-json": "*",
-                "ext-mbstring": "*",
-                "facade/flare-client-php": "^1.3",
-                "facade/ignition-contracts": "^1.0",
-                "filp/whoops": "^2.4",
-                "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0",
-                "monolog/monolog": "^1.12 || ^2.0",
-                "php": "^7.1|^8.0",
-                "scrivo/highlight.php": "^9.15",
-                "symfony/console": "^3.4 || ^4.0",
-                "symfony/var-dumper": "^3.4 || ^4.0"
+                "php": "^7.0 || ^8.0",
+                "phpoption/phpoption": "^1.8"
             },
             "require-dev": {
-                "mockery/mockery": "~1.3.3|^1.4.2",
-                "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0"
-            },
-            "suggest": {
-                "laravel/telescope": "^2.0"
+                "phpunit/phpunit": "^6.5.14 || ^7.5.20 || ^8.5.19 || ^9.5.8"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.x-dev"
-                },
-                "laravel": {
-                    "providers": [
-                        "Facade\\Ignition\\IgnitionServiceProvider"
-                    ],
-                    "aliases": {
-                        "Flare": "Facade\\Ignition\\Facades\\Flare"
-                    }
-                }
-            },
             "autoload": {
                 "psr-4": {
-                    "Facade\\Ignition\\": "src"
-                },
-                "files": [
-                    "src/helpers.php"
-                ]
+                    "GrahamCampbell\\ResultType\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
-            "description": "A beautiful error page for Laravel applications.",
-            "homepage": "https://github.com/facade/ignition",
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "An Implementation Of The Result Type",
             "keywords": [
-                "error",
-                "flare",
-                "laravel",
-                "page"
+                "Graham Campbell",
+                "GrahamCampbell",
+                "Result Type",
+                "Result-Type",
+                "result"
             ],
             "support": {
-                "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction",
-                "forum": "https://twitter.com/flareappio",
-                "issues": "https://github.com/facade/ignition/issues",
-                "source": "https://github.com/facade/ignition"
+                "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+                "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.0.4"
             },
-            "time": "2021-02-15T10:21:49+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-11-21T21:41:47+00:00"
         },
         {
-            "name": "facade/ignition-contracts",
-            "version": "1.0.2",
+            "name": "guzzlehttp/guzzle",
+            "version": "7.4.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/facade/ignition-contracts.git",
-                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
+                "url": "https://github.com/guzzle/guzzle.git",
+                "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
-                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.3|^8.0"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "^v2.15.8",
-                "phpunit/phpunit": "^9.3.11",
-                "vimeo/psalm": "^3.17.1"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Facade\\IgnitionContracts\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Freek Van der Herten",
-                    "email": "[email protected]",
-                    "homepage": "https://flareapp.io",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Solution contracts for Ignition",
-            "homepage": "https://github.com/facade/ignition-contracts",
-            "keywords": [
-                "contracts",
-                "flare",
-                "ignition"
-            ],
-            "support": {
-                "issues": "https://github.com/facade/ignition-contracts/issues",
-                "source": "https://github.com/facade/ignition-contracts/tree/1.0.2"
-            },
-            "time": "2020-10-16T08:27:54+00:00"
-        },
-        {
-            "name": "fideloper/proxy",
-            "version": "4.4.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/fideloper/TrustedProxy.git",
-                "reference": "c073b2bd04d1c90e04dc1b787662b558dd65ade0"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/fideloper/TrustedProxy/zipball/c073b2bd04d1c90e04dc1b787662b558dd65ade0",
-                "reference": "c073b2bd04d1c90e04dc1b787662b558dd65ade0",
-                "shasum": ""
-            },
-            "require": {
-                "illuminate/contracts": "^5.0|^6.0|^7.0|^8.0|^9.0",
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "illuminate/http": "^5.0|^6.0|^7.0|^8.0|^9.0",
-                "mockery/mockery": "^1.0",
-                "phpunit/phpunit": "^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "laravel": {
-                    "providers": [
-                        "Fideloper\\Proxy\\TrustedProxyServiceProvider"
-                    ]
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Fideloper\\Proxy\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Chris Fidao",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "Set trusted proxies for Laravel",
-            "keywords": [
-                "load balancing",
-                "proxy",
-                "trusted proxy"
-            ],
-            "support": {
-                "issues": "https://github.com/fideloper/TrustedProxy/issues",
-                "source": "https://github.com/fideloper/TrustedProxy/tree/4.4.1"
-            },
-            "time": "2020-10-22T13:48:01+00:00"
-        },
-        {
-            "name": "filp/whoops",
-            "version": "2.12.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/filp/whoops.git",
-                "reference": "c13c0be93cff50f88bbd70827d993026821914dd"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/c13c0be93cff50f88bbd70827d993026821914dd",
-                "reference": "c13c0be93cff50f88bbd70827d993026821914dd",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.5.9 || ^7.0 || ^8.0",
-                "psr/log": "^1.0.1"
-            },
-            "require-dev": {
-                "mockery/mockery": "^0.9 || ^1.0",
-                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3",
-                "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0"
-            },
-            "suggest": {
-                "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
-                "whoops/soap": "Formats errors as SOAP responses"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.7-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Whoops\\": "src/Whoops/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Filipe Dobreira",
-                    "homepage": "https://github.com/filp",
-                    "role": "Developer"
-                }
-            ],
-            "description": "php error handling for cool kids",
-            "homepage": "https://filp.github.io/whoops/",
-            "keywords": [
-                "error",
-                "exception",
-                "handling",
-                "library",
-                "throwable",
-                "whoops"
-            ],
-            "support": {
-                "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.12.1"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/denis-sokolov",
-                    "type": "github"
-                }
-            ],
-            "time": "2021-04-25T12:00:00+00:00"
-        },
-        {
-            "name": "guzzlehttp/guzzle",
-            "version": "7.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "7008573787b430c1c1f650e3722d9bba59967628"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7008573787b430c1c1f650e3722d9bba59967628",
-                "reference": "7008573787b430c1c1f650e3722d9bba59967628",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee0a041b1760e6a53d2a39c8c34115adc2af2c79",
+                "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "guzzlehttp/promises": "^1.4",
-                "guzzlehttp/psr7": "^1.7 || ^2.0",
+                "guzzlehttp/promises": "^1.5",
+                "guzzlehttp/psr7": "^1.8.3 || ^2.1",
                 "php": "^7.2.5 || ^8.0",
-                "psr/http-client": "^1.0"
+                "psr/http-client": "^1.0",
+                "symfony/deprecation-contracts": "^2.2 || ^3.0"
             },
             "provide": {
                 "psr/http-client-implementation": "1.0"
                 "ext-curl": "*",
                 "php-http/client-integration-tests": "^3.0",
                 "phpunit/phpunit": "^8.5.5 || ^9.3.5",
-                "psr/log": "^1.1"
+                "psr/log": "^1.1 || ^2.0 || ^3.0"
             },
             "suggest": {
                 "ext-curl": "Required for CURL handler support",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "7.3-dev"
+                    "dev-master": "7.4-dev"
                 }
             },
             "autoload": {
                 "MIT"
             ],
             "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
                 {
                     "name": "Michael Dowling",
                     "email": "[email protected]",
                     "homepage": "https://github.com/mtdowling"
                 },
+                {
+                    "name": "Jeremy Lindblom",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/jeremeamia"
+                },
+                {
+                    "name": "George Mponos",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Nyholm"
+                },
                 {
                     "name": "Márk Sági-Kazár",
                     "email": "[email protected]",
-                    "homepage": "https://sagikazarmark.hu"
+                    "homepage": "https://github.com/sagikazarmark"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Tobion"
                 }
             ],
             "description": "Guzzle is a PHP HTTP client library",
-            "homepage": "http://guzzlephp.org/",
             "keywords": [
                 "client",
                 "curl",
             ],
             "support": {
                 "issues": "https://github.com/guzzle/guzzle/issues",
-                "source": "https://github.com/guzzle/guzzle/tree/7.3.0"
+                "source": "https://github.com/guzzle/guzzle/tree/7.4.1"
             },
             "funding": [
                 {
                     "type": "github"
                 },
                 {
-                    "url": "https://github.com/alexeyshockov",
-                    "type": "github"
-                },
-                {
-                    "url": "https://github.com/gmponos",
-                    "type": "github"
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2021-03-23T11:33:13+00:00"
+            "time": "2021-12-06T18:43:05+00:00"
         },
         {
             "name": "guzzlehttp/promises",
-            "version": "1.4.1",
+            "version": "1.5.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/promises.git",
-                "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d"
+                "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d",
-                "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/fe752aedc9fd8fcca3fe7ad05d419d32998a06da",
+                "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.4-dev"
+                    "dev-master": "1.5-dev"
                 }
             },
             "autoload": {
                 "MIT"
             ],
             "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
                 {
                     "name": "Michael Dowling",
                     "email": "[email protected]",
                     "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Tobion"
                 }
             ],
             "description": "Guzzle promises library",
             ],
             "support": {
                 "issues": "https://github.com/guzzle/promises/issues",
-                "source": "https://github.com/guzzle/promises/tree/1.4.1"
+                "source": "https://github.com/guzzle/promises/tree/1.5.1"
             },
-            "time": "2021-03-07T09:25:29+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-10-22T20:56:57+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
-            "version": "1.8.2",
+            "version": "2.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/psr7.git",
-                "reference": "dc960a912984efb74d0a90222870c72c87f10c91"
+                "reference": "089edd38f5b8abba6cb01567c2a8aaa47cec4c72"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91",
-                "reference": "dc960a912984efb74d0a90222870c72c87f10c91",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/089edd38f5b8abba6cb01567c2a8aaa47cec4c72",
+                "reference": "089edd38f5b8abba6cb01567c2a8aaa47cec4c72",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.4.0",
-                "psr/http-message": "~1.0",
-                "ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
+                "php": "^7.2.5 || ^8.0",
+                "psr/http-factory": "^1.0",
+                "psr/http-message": "^1.0",
+                "ralouphie/getallheaders": "^3.0"
             },
             "provide": {
+                "psr/http-factory-implementation": "1.0",
                 "psr/http-message-implementation": "1.0"
             },
             "require-dev": {
-                "ext-zlib": "*",
-                "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10"
+                "bamarni/composer-bin-plugin": "^1.4.1",
+                "http-interop/http-factory-tests": "^0.9",
+                "phpunit/phpunit": "^8.5.8 || ^9.3.10"
             },
             "suggest": {
                 "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.7-dev"
+                    "dev-master": "2.1-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
                     "GuzzleHttp\\Psr7\\": "src/"
-                },
-                "files": [
-                    "src/functions_include.php"
-                ]
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
             "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
                 {
                     "name": "Michael Dowling",
                     "email": "[email protected]",
                     "homepage": "https://github.com/mtdowling"
                 },
+                {
+                    "name": "George Mponos",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/sagikazarmark"
+                },
                 {
                     "name": "Tobias Schultze",
+                    "email": "[email protected]",
                     "homepage": "https://github.com/Tobion"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "[email protected]",
+                    "homepage": "https://sagikazarmark.hu"
                 }
             ],
             "description": "PSR-7 message implementation that also provides common utility methods",
             ],
             "support": {
                 "issues": "https://github.com/guzzle/psr7/issues",
-                "source": "https://github.com/guzzle/psr7/tree/1.8.2"
+                "source": "https://github.com/guzzle/psr7/tree/2.1.0"
             },
-            "time": "2021-04-26T09:17:50+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-10-06T17:43:30+00:00"
         },
         {
             "name": "intervention/image",
-            "version": "2.5.1",
+            "version": "2.7.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Intervention/image.git",
-                "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e"
+                "reference": "744ebba495319501b873a4e48787759c72e3fb8c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Intervention/image/zipball/abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
-                "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
+                "url": "https://api.github.com/repos/Intervention/image/zipball/744ebba495319501b873a4e48787759c72e3fb8c",
+                "reference": "744ebba495319501b873a4e48787759c72e3fb8c",
                 "shasum": ""
             },
             "require": {
                 "ext-fileinfo": "*",
-                "guzzlehttp/psr7": "~1.1",
+                "guzzlehttp/psr7": "~1.1 || ^2.0",
                 "php": ">=5.4.0"
             },
             "require-dev": {
                 "mockery/mockery": "~0.9.2",
-                "phpunit/phpunit": "^4.8 || ^5.7"
+                "phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
             },
             "suggest": {
                 "ext-gd": "to use GD library based image processing.",
             ],
             "support": {
                 "issues": "https://github.com/Intervention/image/issues",
-                "source": "https://github.com/Intervention/image/tree/master"
+                "source": "https://github.com/Intervention/image/tree/2.7.1"
             },
-            "time": "2019-11-02T09:15:47+00:00"
+            "funding": [
+                {
+                    "url": "https://www.paypal.me/interventionphp",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/Intervention",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-12-16T16:49:26+00:00"
         },
         {
             "name": "knplabs/knp-snappy",
-            "version": "v1.2.1",
+            "version": "v1.4.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/KnpLabs/snappy.git",
-                "reference": "7bac60fb729147b7ccd8532c07df3f52a4afa8a4"
+                "reference": "5126fb5b335ec929a226314d40cd8dad497c3d67"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/7bac60fb729147b7ccd8532c07df3f52a4afa8a4",
-                "reference": "7bac60fb729147b7ccd8532c07df3f52a4afa8a4",
+                "url": "https://api.github.com/repos/KnpLabs/snappy/zipball/5126fb5b335ec929a226314d40cd8dad497c3d67",
+                "reference": "5126fb5b335ec929a226314d40cd8dad497c3d67",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1",
-                "psr/log": "^1.0",
-                "symfony/process": "~3.4||~4.3||~5.0"
+                "psr/log": "^1.0||^2.0||^3.0",
+                "symfony/process": "~3.4||~4.3||~5.0||~6.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "~7.4"
+                "friendsofphp/php-cs-fixer": "^2.16||^3.0",
+                "pedrotroller/php-cs-custom-fixer": "^2.19",
+                "phpstan/phpstan": "^0.12.7",
+                "phpstan/phpstan-phpunit": "^0.12.6",
+                "phpunit/phpunit": "~7.4||~8.5"
             },
             "suggest": {
                 "h4cc/wkhtmltoimage-amd64": "Provides wkhtmltoimage-amd64 binary for Linux-compatible machines, use version `~0.12` as dependency",
             ],
             "authors": [
                 {
-                    "name": "KnpLabs Team",
+                    "name": "KNP Labs Team",
                     "homepage": "http://knplabs.com"
                 },
                 {
                     "homepage": "http://github.com/KnpLabs/snappy/contributors"
                 }
             ],
-            "description": "PHP5 library allowing thumbnail, snapshot or PDF generation from a url or a html page. Wrapper for wkhtmltopdf/wkhtmltoimage.",
+            "description": "PHP library allowing thumbnail, snapshot or PDF generation from a url or a html page. Wrapper for wkhtmltopdf/wkhtmltoimage.",
             "homepage": "http://github.com/KnpLabs/snappy",
             "keywords": [
                 "knp",
             ],
             "support": {
                 "issues": "https://github.com/KnpLabs/snappy/issues",
-                "source": "https://github.com/KnpLabs/snappy/tree/master"
+                "source": "https://github.com/KnpLabs/snappy/tree/v1.4.1"
             },
-            "time": "2020-01-20T08:30:30+00:00"
+            "time": "2022-01-07T13:03:38+00:00"
         },
         {
             "name": "laravel/framework",
-            "version": "v6.20.26",
+            "version": "v8.80.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "0117d797dc1ab64b1f88d4f6b966380ea7def091"
+                "reference": "8949a2e46b0f274f39c61eee8d5de1dc6a1f686b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/0117d797dc1ab64b1f88d4f6b966380ea7def091",
-                "reference": "0117d797dc1ab64b1f88d4f6b966380ea7def091",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/8949a2e46b0f274f39c61eee8d5de1dc6a1f686b",
+                "reference": "8949a2e46b0f274f39c61eee8d5de1dc6a1f686b",
                 "shasum": ""
             },
             "require": {
                 "doctrine/inflector": "^1.4|^2.0",
-                "dragonmantank/cron-expression": "^2.3.1",
+                "dragonmantank/cron-expression": "^3.0.2",
                 "egulias/email-validator": "^2.1.10",
                 "ext-json": "*",
                 "ext-mbstring": "*",
                 "ext-openssl": "*",
-                "league/commonmark": "^1.3",
+                "laravel/serializable-closure": "^1.0",
+                "league/commonmark": "^1.3|^2.0.2",
                 "league/flysystem": "^1.1",
-                "monolog/monolog": "^1.12|^2.0",
-                "nesbot/carbon": "^2.31",
+                "monolog/monolog": "^2.0",
+                "nesbot/carbon": "^2.53.1",
                 "opis/closure": "^3.6",
-                "php": "^7.2.5|^8.0",
+                "php": "^7.3|^8.0",
                 "psr/container": "^1.0",
+                "psr/log": "^1.0|^2.0",
                 "psr/simple-cache": "^1.0",
-                "ramsey/uuid": "^3.7",
-                "swiftmailer/swiftmailer": "^6.0",
-                "symfony/console": "^4.3.4",
-                "symfony/debug": "^4.3.4",
-                "symfony/finder": "^4.3.4",
-                "symfony/http-foundation": "^4.3.4",
-                "symfony/http-kernel": "^4.3.4",
-                "symfony/polyfill-php73": "^1.17",
-                "symfony/process": "^4.3.4",
-                "symfony/routing": "^4.3.4",
-                "symfony/var-dumper": "^4.3.4",
-                "tijsverkoyen/css-to-inline-styles": "^2.2.1",
-                "vlucas/phpdotenv": "^3.3"
+                "ramsey/uuid": "^4.2.2",
+                "swiftmailer/swiftmailer": "^6.3",
+                "symfony/console": "^5.4",
+                "symfony/error-handler": "^5.4",
+                "symfony/finder": "^5.4",
+                "symfony/http-foundation": "^5.4",
+                "symfony/http-kernel": "^5.4",
+                "symfony/mime": "^5.4",
+                "symfony/process": "^5.4",
+                "symfony/routing": "^5.4",
+                "symfony/var-dumper": "^5.4",
+                "tijsverkoyen/css-to-inline-styles": "^2.2.2",
+                "vlucas/phpdotenv": "^5.4.1",
+                "voku/portable-ascii": "^1.4.8"
             },
             "conflict": {
                 "tightenco/collect": "<5.5.33"
             },
+            "provide": {
+                "psr/container-implementation": "1.0",
+                "psr/simple-cache-implementation": "1.0"
+            },
             "replace": {
                 "illuminate/auth": "self.version",
                 "illuminate/broadcasting": "self.version",
                 "illuminate/bus": "self.version",
                 "illuminate/cache": "self.version",
+                "illuminate/collections": "self.version",
                 "illuminate/config": "self.version",
                 "illuminate/console": "self.version",
                 "illuminate/container": "self.version",
                 "illuminate/hashing": "self.version",
                 "illuminate/http": "self.version",
                 "illuminate/log": "self.version",
+                "illuminate/macroable": "self.version",
                 "illuminate/mail": "self.version",
                 "illuminate/notifications": "self.version",
                 "illuminate/pagination": "self.version",
                 "illuminate/routing": "self.version",
                 "illuminate/session": "self.version",
                 "illuminate/support": "self.version",
+                "illuminate/testing": "self.version",
                 "illuminate/translation": "self.version",
                 "illuminate/validation": "self.version",
                 "illuminate/view": "self.version"
             },
             "require-dev": {
-                "aws/aws-sdk-php": "^3.155",
-                "doctrine/dbal": "^2.6",
-                "filp/whoops": "^2.8",
-                "guzzlehttp/guzzle": "^6.3.1|^7.0.1",
+                "aws/aws-sdk-php": "^3.198.1",
+                "doctrine/dbal": "^2.13.3|^3.1.4",
+                "filp/whoops": "^2.14.3",
+                "guzzlehttp/guzzle": "^6.5.5|^7.0.1",
                 "league/flysystem-cached-adapter": "^1.0",
-                "mockery/mockery": "~1.3.3|^1.4.2",
-                "moontoast/math": "^1.1",
-                "orchestra/testbench-core": "^4.8",
+                "mockery/mockery": "^1.4.4",
+                "orchestra/testbench-core": "^6.27",
                 "pda/pheanstalk": "^4.0",
-                "phpunit/phpunit": "^7.5.15|^8.4|^9.3.3",
-                "predis/predis": "^1.1.1",
-                "symfony/cache": "^4.3.4"
+                "phpunit/phpunit": "^8.5.19|^9.5.8",
+                "predis/predis": "^1.1.9",
+                "symfony/cache": "^5.4"
             },
             "suggest": {
-                "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.155).",
-                "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.6).",
+                "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
+                "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.198.1).",
+                "brianium/paratest": "Required to run tests in parallel (^6.0).",
+                "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).",
+                "ext-bcmath": "Required to use the multiple_of validation rule.",
                 "ext-ftp": "Required to use the Flysystem FTP driver.",
                 "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().",
                 "ext-memcached": "Required to use the memcache cache driver.",
                 "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).",
                 "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).",
-                "filp/whoops": "Required for friendly error pages in development (^2.8).",
-                "guzzlehttp/guzzle": "Required to use the Mailgun mail driver and the ping methods on schedules (^6.3.1|^7.0.1).",
+                "filp/whoops": "Required for friendly error pages in development (^2.14.3).",
+                "guzzlehttp/guzzle": "Required to use the HTTP Client, Mailgun mail driver and the ping methods on schedules (^6.5.5|^7.0.1).",
                 "laravel/tinker": "Required to use the tinker console command (^2.0).",
                 "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0).",
                 "league/flysystem-cached-adapter": "Required to use the Flysystem cache (^1.0).",
                 "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).",
-                "moontoast/math": "Required to use ordered UUIDs (^1.1).",
+                "mockery/mockery": "Required to use mocking (^1.4.4).",
                 "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).",
                 "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).",
-                "predis/predis": "Required to use the predis connector (^1.1.2).",
+                "phpunit/phpunit": "Required to use assertions and run tests (^8.5.19|^9.5.8).",
+                "predis/predis": "Required to use the predis connector (^1.1.9).",
                 "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 (^4.0).",
-                "symfony/cache": "Required to PSR-6 cache bridge (^4.3.4).",
-                "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^1.2).",
+                "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0|^5.0|^6.0|^7.0).",
+                "symfony/cache": "Required to PSR-6 cache bridge (^5.4).",
+                "symfony/filesystem": "Required to enable support for relative symbolic links (^5.4).",
+                "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0).",
                 "wildbit/swiftmailer-postmark": "Required to use Postmark mail driver (^3.0)."
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "6.x-dev"
+                    "dev-master": "8.x-dev"
                 }
             },
             "autoload": {
                 "files": [
+                    "src/Illuminate/Collections/helpers.php",
+                    "src/Illuminate/Events/functions.php",
                     "src/Illuminate/Foundation/helpers.php",
                     "src/Illuminate/Support/helpers.php"
                 ],
                 "psr-4": {
-                    "Illuminate\\": "src/Illuminate/"
+                    "Illuminate\\": "src/Illuminate/",
+                    "Illuminate\\Support\\": [
+                        "src/Illuminate/Macroable/",
+                        "src/Illuminate/Collections/"
+                    ]
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
-            "time": "2021-04-28T14:38:32+00:00"
+            "time": "2022-01-18T15:51:42+00:00"
         },
         {
-            "name": "laravel/socialite",
-            "version": "v5.2.3",
+            "name": "laravel/serializable-closure",
+            "version": "v1.0.5",
             "source": {
                 "type": "git",
-                "url": "https://github.com/laravel/socialite.git",
-                "reference": "1960802068f81e44b2ae9793932181cf1cb91b5c"
+                "url": "https://github.com/laravel/serializable-closure.git",
+                "reference": "25de3be1bca1b17d52ff0dc02b646c667ac7266c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/socialite/zipball/1960802068f81e44b2ae9793932181cf1cb91b5c",
-                "reference": "1960802068f81e44b2ae9793932181cf1cb91b5c",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/25de3be1bca1b17d52ff0dc02b646c667ac7266c",
+                "reference": "25de3be1bca1b17d52ff0dc02b646c667ac7266c",
                 "shasum": ""
             },
             "require": {
-                "ext-json": "*",
-                "guzzlehttp/guzzle": "^6.0|^7.0",
-                "illuminate/http": "^6.0|^7.0|^8.0",
-                "illuminate/support": "^6.0|^7.0|^8.0",
-                "league/oauth1-client": "^1.0",
-                "php": "^7.2|^8.0"
+                "php": "^7.3|^8.0"
             },
             "require-dev": {
-                "illuminate/contracts": "^6.0|^7.0",
-                "mockery/mockery": "^1.0",
-                "orchestra/testbench": "^4.0|^5.0|^6.0",
-                "phpunit/phpunit": "^8.0|^9.3"
+                "pestphp/pest": "^1.18",
+                "phpstan/phpstan": "^0.12.98",
+                "symfony/var-dumper": "^5.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\SerializableClosure\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Nuno Maduro",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
+            "keywords": [
+                "closure",
+                "laravel",
+                "serializable"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/serializable-closure/issues",
+                "source": "https://github.com/laravel/serializable-closure"
+            },
+            "time": "2021-11-30T15:53:04+00:00"
+        },
+        {
+            "name": "laravel/socialite",
+            "version": "v5.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/socialite.git",
+                "reference": "4e6f7e40de9a54ad641de5b8e29cdf1e73842e10"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/socialite/zipball/4e6f7e40de9a54ad641de5b8e29cdf1e73842e10",
+                "reference": "4e6f7e40de9a54ad641de5b8e29cdf1e73842e10",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "guzzlehttp/guzzle": "^6.0|^7.0",
+                "illuminate/http": "^6.0|^7.0|^8.0|^9.0",
+                "illuminate/support": "^6.0|^7.0|^8.0|^9.0",
+                "league/oauth1-client": "^1.0",
+                "php": "^7.2|^8.0"
+            },
+            "require-dev": {
+                "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0",
+                "mockery/mockery": "^1.0",
+                "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0",
+                "phpunit/phpunit": "^8.0|^9.3"
             },
             "type": "library",
             "extra": {
                 "issues": "https://github.com/laravel/socialite/issues",
                 "source": "https://github.com/laravel/socialite"
             },
-            "time": "2021-04-06T14:38:16+00:00"
+            "time": "2022-01-12T18:05:39+00:00"
+        },
+        {
+            "name": "laravel/tinker",
+            "version": "v2.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/tinker.git",
+                "reference": "5f2f9815b7631b9f586a3de7933c25f9327d4073"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/tinker/zipball/5f2f9815b7631b9f586a3de7933c25f9327d4073",
+                "reference": "5f2f9815b7631b9f586a3de7933c25f9327d4073",
+                "shasum": ""
+            },
+            "require": {
+                "illuminate/console": "^6.0|^7.0|^8.0|^9.0",
+                "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0",
+                "illuminate/support": "^6.0|^7.0|^8.0|^9.0",
+                "php": "^7.2.5|^8.0",
+                "psy/psysh": "^0.10.4|^0.11.1",
+                "symfony/var-dumper": "^4.3.4|^5.0|^6.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "~1.3.3|^1.4.2",
+                "phpunit/phpunit": "^8.5.8|^9.3.3"
+            },
+            "suggest": {
+                "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0)."
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                },
+                "laravel": {
+                    "providers": [
+                        "Laravel\\Tinker\\TinkerServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\Tinker\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "Powerful REPL for the Laravel framework.",
+            "keywords": [
+                "REPL",
+                "Tinker",
+                "laravel",
+                "psysh"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/tinker/issues",
+                "source": "https://github.com/laravel/tinker/tree/v2.7.0"
+            },
+            "time": "2022-01-10T08:52:49+00:00"
+        },
+        {
+            "name": "laravel/ui",
+            "version": "v3.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/ui.git",
+                "reference": "9a1e52442dd238647905b98d773d59e438eb9f9d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/ui/zipball/9a1e52442dd238647905b98d773d59e438eb9f9d",
+                "reference": "9a1e52442dd238647905b98d773d59e438eb9f9d",
+                "shasum": ""
+            },
+            "require": {
+                "illuminate/console": "^8.42|^9.0",
+                "illuminate/filesystem": "^8.42|^9.0",
+                "illuminate/support": "^8.42|^9.0",
+                "illuminate/validation": "^8.42|^9.0",
+                "php": "^7.3|^8.0"
+            },
+            "require-dev": {
+                "orchestra/testbench": "^6.23|^7.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                },
+                "laravel": {
+                    "providers": [
+                        "Laravel\\Ui\\UiServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\Ui\\": "src/",
+                    "Illuminate\\Foundation\\Auth\\": "auth-backend/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "Laravel UI utilities and presets.",
+            "keywords": [
+                "laravel",
+                "ui"
+            ],
+            "support": {
+                "source": "https://github.com/laravel/ui/tree/v3.4.1"
+            },
+            "time": "2021-12-22T10:40:50+00:00"
         },
         {
             "name": "league/commonmark",
-            "version": "1.6.0",
+            "version": "1.6.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/commonmark.git",
-                "reference": "19a9673b833cc37770439097b381d86cd125bfe8"
+                "reference": "2b8185c13bc9578367a5bf901881d1c1b5bbd09b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/19a9673b833cc37770439097b381d86cd125bfe8",
-                "reference": "19a9673b833cc37770439097b381d86cd125bfe8",
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/2b8185c13bc9578367a5bf901881d1c1b5bbd09b",
+                "reference": "2b8185c13bc9578367a5bf901881d1c1b5bbd09b",
                 "shasum": ""
             },
             "require": {
                 "github/gfm": "0.29.0",
                 "michelf/php-markdown": "~1.4",
                 "mikehaertl/php-shellcommand": "^1.4",
-                "phpstan/phpstan": "^0.12",
+                "phpstan/phpstan": "^0.12.90",
                 "phpunit/phpunit": "^7.5 || ^8.5 || ^9.2",
                 "scrutinizer/ocular": "^1.5",
                 "symfony/finder": "^4.2"
                 "source": "https://github.com/thephpleague/commonmark"
             },
             "funding": [
-                {
-                    "url": "https://enjoy.gitstore.app/repositories/thephpleague/commonmark",
-                    "type": "custom"
-                },
                 {
                     "url": "https://www.colinodell.com/sponsor",
                     "type": "custom"
                     "url": "https://github.com/colinodell",
                     "type": "github"
                 },
-                {
-                    "url": "https://www.patreon.com/colinodell",
-                    "type": "patreon"
-                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/league/commonmark",
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-05-01T19:00:49+00:00"
+            "time": "2022-01-13T17:18:13+00:00"
         },
         {
             "name": "league/flysystem",
-            "version": "1.1.3",
+            "version": "1.1.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem.git",
-                "reference": "9be3b16c877d477357c015cec057548cf9b2a14a"
+                "reference": "094defdb4a7001845300334e7c1ee2335925ef99"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/9be3b16c877d477357c015cec057548cf9b2a14a",
-                "reference": "9be3b16c877d477357c015cec057548cf9b2a14a",
+                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/094defdb4a7001845300334e7c1ee2335925ef99",
+                "reference": "094defdb4a7001845300334e7c1ee2335925ef99",
                 "shasum": ""
             },
             "require": {
                 "phpunit/phpunit": "^8.5.8"
             },
             "suggest": {
-                "ext-fileinfo": "Required for MimeType",
                 "ext-ftp": "Allows you to use FTP server storage",
                 "ext-openssl": "Allows you to use FTPS server storage",
                 "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2",
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/flysystem/issues",
-                "source": "https://github.com/thephpleague/flysystem/tree/1.x"
+                "source": "https://github.com/thephpleague/flysystem/tree/1.1.9"
             },
             "funding": [
                 {
                     "type": "other"
                 }
             ],
-            "time": "2020-08-23T07:39:11+00:00"
+            "time": "2021-12-09T09:40:50+00:00"
         },
         {
             "name": "league/flysystem-aws-s3-v3",
             },
             "time": "2020-10-08T18:58:37+00:00"
         },
+        {
+            "name": "league/html-to-markdown",
+            "version": "5.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/html-to-markdown.git",
+                "reference": "4d0394e120dc14b0d5c52fd1755fd48656da2ec9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/4d0394e120dc14b0d5c52fd1755fd48656da2ec9",
+                "reference": "4d0394e120dc14b0d5c52fd1755fd48656da2ec9",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-xml": "*",
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "mikehaertl/php-shellcommand": "^1.1.0",
+                "phpstan/phpstan": "^0.12.82",
+                "phpunit/phpunit": "^8.5 || ^9.2",
+                "scrutinizer/ocular": "^1.6",
+                "unleashedtech/php-coding-standard": "^2.7",
+                "vimeo/psalm": "^4.6"
+            },
+            "bin": [
+                "bin/html-to-markdown"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\HTMLToMarkdown\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Colin O'Dell",
+                    "email": "[email protected]",
+                    "homepage": "https://www.colinodell.com",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Nick Cernis",
+                    "email": "[email protected]",
+                    "homepage": "http://modernnerd.net",
+                    "role": "Original Author"
+                }
+            ],
+            "description": "An HTML-to-markdown conversion helper for PHP",
+            "homepage": "https://github.com/thephpleague/html-to-markdown",
+            "keywords": [
+                "html",
+                "markdown"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/html-to-markdown/issues",
+                "source": "https://github.com/thephpleague/html-to-markdown/tree/5.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://www.colinodell.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.paypal.me/colinpodell/10.00",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/colinodell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-11-06T05:38:26+00:00"
+        },
         {
             "name": "league/mime-type-detection",
-            "version": "1.7.0",
+            "version": "1.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/mime-type-detection.git",
-                "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3"
+                "reference": "aa70e813a6ad3d1558fc927863d47309b4c23e69"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3",
-                "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3",
+                "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/aa70e813a6ad3d1558fc927863d47309b4c23e69",
+                "reference": "aa70e813a6ad3d1558fc927863d47309b4c23e69",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.2 || ^8.0"
             },
             "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.18",
+                "friendsofphp/php-cs-fixer": "^3.2",
                 "phpstan/phpstan": "^0.12.68",
                 "phpunit/phpunit": "^8.5.8 || ^9.3"
             },
             "description": "Mime-type detection for Flysystem",
             "support": {
                 "issues": "https://github.com/thephpleague/mime-type-detection/issues",
-                "source": "https://github.com/thephpleague/mime-type-detection/tree/1.7.0"
+                "source": "https://github.com/thephpleague/mime-type-detection/tree/1.9.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-18T20:58:21+00:00"
+            "time": "2021-11-21T11:48:40+00:00"
         },
         {
             "name": "league/oauth1-client",
-            "version": "v1.9.0",
+            "version": "v1.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/oauth1-client.git",
-                "reference": "1e7e6be2dc543bf466236fb171e5b20e1b06aee6"
+                "reference": "88dd16b0cff68eb9167bfc849707d2c40ad91ddc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/1e7e6be2dc543bf466236fb171e5b20e1b06aee6",
-                "reference": "1e7e6be2dc543bf466236fb171e5b20e1b06aee6",
+                "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/88dd16b0cff68eb9167bfc849707d2c40ad91ddc",
+                "reference": "88dd16b0cff68eb9167bfc849707d2c40ad91ddc",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "ext-openssl": "*",
                 "guzzlehttp/guzzle": "^6.0|^7.0",
+                "guzzlehttp/psr7": "^1.7|^2.0",
                 "php": ">=7.1||>=8.0"
             },
             "require-dev": {
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/oauth1-client/issues",
-                "source": "https://github.com/thephpleague/oauth1-client/tree/v1.9.0"
+                "source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.0"
+            },
+            "time": "2021-08-15T23:05:49+00:00"
+        },
+        {
+            "name": "league/oauth2-client",
+            "version": "2.6.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/oauth2-client.git",
+                "reference": "2334c249907190c132364f5dae0287ab8666aa19"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/2334c249907190c132364f5dae0287ab8666aa19",
+                "reference": "2334c249907190c132364f5dae0287ab8666aa19",
+                "shasum": ""
+            },
+            "require": {
+                "guzzlehttp/guzzle": "^6.0 || ^7.0",
+                "paragonie/random_compat": "^1 || ^2 || ^9.99",
+                "php": "^5.6 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "mockery/mockery": "^1.3.5",
+                "php-parallel-lint/php-parallel-lint": "^1.3.1",
+                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5",
+                "squizlabs/php_codesniffer": "^2.3 || ^3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-2.x": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\OAuth2\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Alex Bilbie",
+                    "email": "[email protected]",
+                    "homepage": "http://www.alexbilbie.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Woody Gilk",
+                    "homepage": "https://github.com/shadowhand",
+                    "role": "Contributor"
+                }
+            ],
+            "description": "OAuth 2.0 Client Library",
+            "keywords": [
+                "Authentication",
+                "SSO",
+                "authorization",
+                "identity",
+                "idp",
+                "oauth",
+                "oauth2",
+                "single sign on"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/oauth2-client/issues",
+                "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.1"
             },
-            "time": "2021-01-20T01:40:53+00:00"
+            "time": "2021-12-22T16:42:49+00:00"
         },
         {
             "name": "monolog/monolog",
-            "version": "2.2.0",
+            "version": "2.3.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084"
+                "reference": "fd4380d6fc37626e2f799f29d91195040137eba9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1cb1cde8e8dd0f70cc0fe51354a59acad9302084",
-                "reference": "1cb1cde8e8dd0f70cc0fe51354a59acad9302084",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd4380d6fc37626e2f799f29d91195040137eba9",
+                "reference": "fd4380d6fc37626e2f799f29d91195040137eba9",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2",
-                "psr/log": "^1.0.1"
+                "psr/log": "^1.0.1 || ^2.0 || ^3.0"
             },
             "provide": {
-                "psr/log-implementation": "1.0.0"
+                "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0"
             },
             "require-dev": {
                 "aws/aws-sdk-php": "^2.4.9 || ^3.0",
                 "elasticsearch/elasticsearch": "^7",
                 "graylog2/gelf-php": "^1.4.2",
                 "mongodb/mongodb": "^1.8",
-                "php-amqplib/php-amqplib": "~2.4",
+                "php-amqplib/php-amqplib": "~2.4 || ^3",
                 "php-console/php-console": "^3.1.3",
                 "phpspec/prophecy": "^1.6.1",
-                "phpstan/phpstan": "^0.12.59",
+                "phpstan/phpstan": "^0.12.91",
                 "phpunit/phpunit": "^8.5",
                 "predis/predis": "^1.1",
                 "rollbar/rollbar": "^1.3",
-                "ruflin/elastica": ">=0.90 <7.0.1",
+                "ruflin/elastica": ">=0.90@dev",
                 "swiftmailer/swiftmailer": "^5.3|^6.0"
             },
             "suggest": {
                 "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
                 "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
                 "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+                "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
                 "ext-mbstring": "Allow to work properly with unicode symbols",
                 "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+                "ext-openssl": "Required to send log messages using SSL",
+                "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
                 "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
                 "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
                 "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/monolog/issues",
-                "source": "https://github.com/Seldaek/monolog/tree/2.2.0"
+                "source": "https://github.com/Seldaek/monolog/tree/2.3.5"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-12-14T13:15:25+00:00"
+            "time": "2021-10-01T21:08:31+00:00"
         },
         {
             "name": "mtdowling/jmespath.php",
-            "version": "2.6.0",
+            "version": "2.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/jmespath/jmespath.php.git",
-                "reference": "42dae2cbd13154083ca6d70099692fef8ca84bfb"
+                "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/42dae2cbd13154083ca6d70099692fef8ca84bfb",
-                "reference": "42dae2cbd13154083ca6d70099692fef8ca84bfb",
+                "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9b87907a81b87bc76d19a7fb2d61e61486ee9edb",
+                "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb",
                 "shasum": ""
             },
             "require": {
                 "symfony/polyfill-mbstring": "^1.17"
             },
             "require-dev": {
-                "composer/xdebug-handler": "^1.4",
+                "composer/xdebug-handler": "^1.4 || ^2.0",
                 "phpunit/phpunit": "^4.8.36 || ^7.5.15"
             },
             "bin": [
             ],
             "support": {
                 "issues": "https://github.com/jmespath/jmespath.php/issues",
-                "source": "https://github.com/jmespath/jmespath.php/tree/2.6.0"
+                "source": "https://github.com/jmespath/jmespath.php/tree/2.6.1"
             },
-            "time": "2020-07-31T21:01:56+00:00"
+            "time": "2021-06-14T00:11:39+00:00"
         },
         {
             "name": "nesbot/carbon",
-            "version": "2.47.0",
+            "version": "2.56.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/briannesbitt/Carbon.git",
-                "reference": "606262fd8888b75317ba9461825a24fc34001e1e"
+                "reference": "626ec8cbb724cd3c3400c3ed8f730545b744e3f4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/606262fd8888b75317ba9461825a24fc34001e1e",
-                "reference": "606262fd8888b75317ba9461825a24fc34001e1e",
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/626ec8cbb724cd3c3400c3ed8f730545b744e3f4",
+                "reference": "626ec8cbb724cd3c3400c3ed8f730545b744e3f4",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "php": "^7.1.8 || ^8.0",
                 "symfony/polyfill-mbstring": "^1.0",
-                "symfony/translation": "^3.4 || ^4.0 || ^5.0"
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0"
             },
             "require-dev": {
+                "doctrine/dbal": "^2.0 || ^3.0",
                 "doctrine/orm": "^2.7",
-                "friendsofphp/php-cs-fixer": "^2.14 || ^3.0",
+                "friendsofphp/php-cs-fixer": "^3.0",
                 "kylekatarnls/multi-tester": "^2.0",
                 "phpmd/phpmd": "^2.9",
                 "phpstan/extension-installer": "^1.0",
-                "phpstan/phpstan": "^0.12.54",
+                "phpstan/phpstan": "^0.12.54 || ^1.0",
                 "phpunit/phpunit": "^7.5.20 || ^8.5.14",
                 "squizlabs/php_codesniffer": "^3.4"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.x-dev",
-                    "dev-3.x": "3.x-dev"
+                    "dev-3.x": "3.x-dev",
+                    "dev-master": "2.x-dev"
                 },
                 "laravel": {
                     "providers": [
                 {
                     "name": "Brian Nesbitt",
                     "email": "[email protected]",
-                    "homepage": "http://nesbot.com"
+                    "homepage": "https://markido.com"
                 },
                 {
                     "name": "kylekatarnls",
-                    "homepage": "http://github.com/kylekatarnls"
+                    "homepage": "https://github.com/kylekatarnls"
                 }
             ],
             "description": "An API extension for DateTime that supports 281 different languages.",
-            "homepage": "http://carbon.nesbot.com",
+            "homepage": "https://carbon.nesbot.com",
             "keywords": [
                 "date",
                 "datetime",
                 "time"
             ],
             "support": {
+                "docs": "https://carbon.nesbot.com/docs",
                 "issues": "https://github.com/briannesbitt/Carbon/issues",
                 "source": "https://github.com/briannesbitt/Carbon"
             },
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-13T21:54:02+00:00"
+            "time": "2022-01-21T17:08:38+00:00"
         },
         {
-            "name": "nunomaduro/collision",
-            "version": "v3.2.0",
+            "name": "nikic/php-parser",
+            "version": "v4.13.2",
             "source": {
                 "type": "git",
-                "url": "https://github.com/nunomaduro/collision.git",
-                "reference": "f7c45764dfe4ba5f2618d265a6f1f9c72732e01d"
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "210577fe3cf7badcc5814d99455df46564f3c077"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f7c45764dfe4ba5f2618d265a6f1f9c72732e01d",
-                "reference": "f7c45764dfe4ba5f2618d265a6f1f9c72732e01d",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/210577fe3cf7badcc5814d99455df46564f3c077",
+                "reference": "210577fe3cf7badcc5814d99455df46564f3c077",
                 "shasum": ""
             },
             "require": {
-                "filp/whoops": "^2.1.4",
-                "php": "^7.2.5 || ^8.0",
-                "php-parallel-lint/php-console-highlighter": "0.5.*",
-                "symfony/console": "~2.8|~3.3|~4.0"
+                "ext-tokenizer": "*",
+                "php": ">=7.0"
             },
             "require-dev": {
-                "laravel/framework": "^6.0",
-                "phpunit/phpunit": "^8.0 || ^9.0"
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
             },
+            "bin": [
+                "bin/php-parse"
+            ],
             "type": "library",
             "extra": {
-                "laravel": {
-                    "providers": [
-                        "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
-                    ]
+                "branch-alias": {
+                    "dev-master": "4.9-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "NunoMaduro\\Collision\\": "src/"
+                    "PhpParser\\": "lib/PhpParser"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "MIT"
+                "BSD-3-Clause"
             ],
             "authors": [
                 {
-                    "name": "Nuno Maduro",
-                    "email": "[email protected]"
+                    "name": "Nikita Popov"
                 }
             ],
-            "description": "Cli error handling for console/command-line PHP applications.",
+            "description": "A PHP parser written in PHP",
             "keywords": [
-                "artisan",
-                "cli",
-                "command-line",
-                "console",
-                "error",
-                "handling",
-                "laravel",
-                "laravel-zero",
-                "php",
-                "symfony"
+                "parser",
+                "php"
             ],
             "support": {
-                "issues": "https://github.com/nunomaduro/collision/issues",
-                "source": "https://github.com/nunomaduro/collision"
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.13.2"
             },
-            "funding": [
-                {
-                    "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/nunomaduro",
-                    "type": "github"
-                },
-                {
-                    "url": "https://www.patreon.com/nunomaduro",
-                    "type": "patreon"
-                }
-            ],
-            "time": "2021-02-11T09:01:42+00:00"
+            "time": "2021-11-30T19:35:32+00:00"
         },
         {
             "name": "onelogin/php-saml",
             },
             "time": "2021-04-09T13:42:10+00:00"
         },
+        {
+            "name": "paragonie/constant_time_encoding",
+            "version": "v2.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/constant_time_encoding.git",
+                "reference": "9229e15f2e6ba772f0c55dd6986c563b937170a8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/9229e15f2e6ba772f0c55dd6986c563b937170a8",
+                "reference": "9229e15f2e6ba772f0c55dd6986c563b937170a8",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7|^8"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^6|^7|^8|^9",
+                "vimeo/psalm": "^1|^2|^3|^4"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ParagonIE\\ConstantTime\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Paragon Initiative Enterprises",
+                    "email": "[email protected]",
+                    "homepage": "https://paragonie.com",
+                    "role": "Maintainer"
+                },
+                {
+                    "name": "Steve 'Sc00bz' Thomas",
+                    "email": "[email protected]",
+                    "homepage": "https://www.tobtu.com",
+                    "role": "Original Developer"
+                }
+            ],
+            "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+            "keywords": [
+                "base16",
+                "base32",
+                "base32_decode",
+                "base32_encode",
+                "base64",
+                "base64_decode",
+                "base64_encode",
+                "bin2hex",
+                "encoding",
+                "hex",
+                "hex2bin",
+                "rfc4648"
+            ],
+            "support": {
+                "email": "[email protected]",
+                "issues": "https://github.com/paragonie/constant_time_encoding/issues",
+                "source": "https://github.com/paragonie/constant_time_encoding"
+            },
+            "time": "2022-01-17T05:32:27+00:00"
+        },
         {
             "name": "paragonie/random_compat",
-            "version": "v9.99.99",
+            "version": "v9.99.100",
             "source": {
                 "type": "git",
                 "url": "https://github.com/paragonie/random_compat.git",
-                "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95"
+                "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95",
-                "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95",
+                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+                "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
                 "shasum": ""
             },
             "require": {
-                "php": "^7"
+                "php": ">= 7"
             },
             "require-dev": {
                 "phpunit/phpunit": "4.*|5.*",
                 "issues": "https://github.com/paragonie/random_compat/issues",
                 "source": "https://github.com/paragonie/random_compat"
             },
-            "time": "2018-07-02T15:55:56+00:00"
+            "time": "2020-10-15T08:29:30+00:00"
         },
         {
             "name": "phenx/php-font-lib",
-            "version": "0.5.2",
+            "version": "0.5.4",
             "source": {
                 "type": "git",
-                "url": "https://github.com/PhenX/php-font-lib.git",
-                "reference": "ca6ad461f032145fff5971b5985e5af9e7fa88d8"
+                "url": "https://github.com/dompdf/php-font-lib.git",
+                "reference": "dd448ad1ce34c63d09baccd05415e361300c35b4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PhenX/php-font-lib/zipball/ca6ad461f032145fff5971b5985e5af9e7fa88d8",
-                "reference": "ca6ad461f032145fff5971b5985e5af9e7fa88d8",
+                "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/dd448ad1ce34c63d09baccd05415e361300c35b4",
+                "reference": "dd448ad1ce34c63d09baccd05415e361300c35b4",
                 "shasum": ""
             },
+            "require": {
+                "ext-mbstring": "*"
+            },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.35 || ^5 || ^6 || ^7"
+                "symfony/phpunit-bridge": "^3 || ^4 || ^5"
             },
             "type": "library",
             "autoload": {
             "description": "A library to read, parse, export and make subsets of different types of font files.",
             "homepage": "https://github.com/PhenX/php-font-lib",
             "support": {
-                "issues": "https://github.com/PhenX/php-font-lib/issues",
-                "source": "https://github.com/PhenX/php-font-lib/tree/0.5.2"
+                "issues": "https://github.com/dompdf/php-font-lib/issues",
+                "source": "https://github.com/dompdf/php-font-lib/tree/0.5.4"
             },
-            "time": "2020-03-08T15:31:32+00:00"
+            "time": "2021-12-17T19:44:54+00:00"
         },
         {
             "name": "phenx/php-svg-lib",
             "time": "2019-09-11T20:02:13+00:00"
         },
         {
-            "name": "php-parallel-lint/php-console-color",
-            "version": "v0.3",
+            "name": "phpoption/phpoption",
+            "version": "1.8.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/php-parallel-lint/PHP-Console-Color.git",
-                "reference": "b6af326b2088f1ad3b264696c9fd590ec395b49e"
+                "url": "https://github.com/schmittjoh/php-option.git",
+                "reference": "eab7a0df01fe2344d172bff4cd6dbd3f8b84ad15"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-parallel-lint/PHP-Console-Color/zipball/b6af326b2088f1ad3b264696c9fd590ec395b49e",
-                "reference": "b6af326b2088f1ad3b264696c9fd590ec395b49e",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/eab7a0df01fe2344d172bff4cd6dbd3f8b84ad15",
+                "reference": "eab7a0df01fe2344d172bff4cd6dbd3f8b84ad15",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.4.0"
-            },
-            "replace": {
-                "jakub-onderka/php-console-color": "*"
+                "php": "^7.0 || ^8.0"
             },
             "require-dev": {
-                "php-parallel-lint/php-code-style": "1.0",
-                "php-parallel-lint/php-parallel-lint": "1.0",
-                "php-parallel-lint/php-var-dump-check": "0.*",
-                "phpunit/phpunit": "~4.3",
-                "squizlabs/php_codesniffer": "1.*"
+                "bamarni/composer-bin-plugin": "^1.4.1",
+                "phpunit/phpunit": "^6.5.14 || ^7.5.20 || ^8.5.19 || ^9.5.8"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.8-dev"
+                }
+            },
             "autoload": {
                 "psr-4": {
-                    "JakubOnderka\\PhpConsoleColor\\": "src/"
+                    "PhpOption\\": "src/PhpOption/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "BSD-2-Clause"
+                "Apache-2.0"
             ],
             "authors": [
                 {
-                    "name": "Jakub Onderka",
-                    "email": "[email protected]"
+                    "name": "Johannes M. Schmitt",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/schmittjoh"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
                 }
             ],
+            "description": "Option Type for PHP",
+            "keywords": [
+                "language",
+                "option",
+                "php",
+                "type"
+            ],
             "support": {
-                "issues": "https://github.com/php-parallel-lint/PHP-Console-Color/issues",
-                "source": "https://github.com/php-parallel-lint/PHP-Console-Color/tree/master"
+                "issues": "https://github.com/schmittjoh/php-option/issues",
+                "source": "https://github.com/schmittjoh/php-option/tree/1.8.1"
             },
-            "time": "2020-05-14T05:47:14+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-12-04T23:24:31+00:00"
         },
         {
-            "name": "php-parallel-lint/php-console-highlighter",
-            "version": "v0.5",
+            "name": "phpseclib/phpseclib",
+            "version": "3.0.12",
             "source": {
                 "type": "git",
-                "url": "https://github.com/php-parallel-lint/PHP-Console-Highlighter.git",
-                "reference": "21bf002f077b177f056d8cb455c5ed573adfdbb8"
+                "url": "https://github.com/phpseclib/phpseclib.git",
+                "reference": "89bfb45bd8b1abc3b37e910d57f5dbd3174f40fb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-parallel-lint/PHP-Console-Highlighter/zipball/21bf002f077b177f056d8cb455c5ed573adfdbb8",
-                "reference": "21bf002f077b177f056d8cb455c5ed573adfdbb8",
+                "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/89bfb45bd8b1abc3b37e910d57f5dbd3174f40fb",
+                "reference": "89bfb45bd8b1abc3b37e910d57f5dbd3174f40fb",
                 "shasum": ""
             },
             "require": {
-                "ext-tokenizer": "*",
-                "php": ">=5.4.0",
-                "php-parallel-lint/php-console-color": "~0.2"
-            },
-            "replace": {
-                "jakub-onderka/php-console-highlighter": "*"
+                "paragonie/constant_time_encoding": "^1|^2",
+                "paragonie/random_compat": "^1.4|^2.0|^9.99.99",
+                "php": ">=5.6.1"
             },
             "require-dev": {
-                "php-parallel-lint/php-code-style": "~1.0",
-                "php-parallel-lint/php-parallel-lint": "~1.0",
-                "php-parallel-lint/php-var-dump-check": "~0.1",
-                "phpunit/phpunit": "~4.0",
-                "squizlabs/php_codesniffer": "~1.5"
+                "phing/phing": "~2.7",
+                "phpunit/phpunit": "^5.7|^6.0|^9.4",
+                "squizlabs/php_codesniffer": "~2.0"
+            },
+            "suggest": {
+                "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
+                "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
+                "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
+                "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
             },
             "type": "library",
             "autoload": {
+                "files": [
+                    "phpseclib/bootstrap.php"
+                ],
                 "psr-4": {
-                    "JakubOnderka\\PhpConsoleHighlighter\\": "src/"
+                    "phpseclib3\\": "phpseclib/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             ],
             "authors": [
                 {
-                    "name": "Jakub Onderka",
-                    "email": "[email protected]",
-                    "homepage": "http://www.acci.cz/"
+                    "name": "Jim Wigginton",
+                    "email": "[email protected]",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Patrick Monnerat",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Andreas Fischer",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Hans-Jürgen Petrich",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "[email protected]",
+                    "role": "Developer"
                 }
             ],
-            "description": "Highlight PHP code in terminal",
+            "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
+            "homepage": "http://phpseclib.sourceforge.net",
+            "keywords": [
+                "BigInteger",
+                "aes",
+                "asn.1",
+                "asn1",
+                "blowfish",
+                "crypto",
+                "cryptography",
+                "encryption",
+                "rsa",
+                "security",
+                "sftp",
+                "signature",
+                "signing",
+                "ssh",
+                "twofish",
+                "x.509",
+                "x509"
+            ],
             "support": {
-                "issues": "https://github.com/php-parallel-lint/PHP-Console-Highlighter/issues",
-                "source": "https://github.com/php-parallel-lint/PHP-Console-Highlighter/tree/master"
+                "issues": "https://github.com/phpseclib/phpseclib/issues",
+                "source": "https://github.com/phpseclib/phpseclib/tree/3.0.12"
             },
-            "time": "2020-05-13T07:37:49+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/terrafrost",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/phpseclib",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-11-28T23:46:03+00:00"
         },
         {
-            "name": "phpoption/phpoption",
-            "version": "1.7.5",
+            "name": "pragmarx/google2fa",
+            "version": "8.0.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/schmittjoh/php-option.git",
-                "reference": "994ecccd8f3283ecf5ac33254543eb0ac946d525"
+                "url": "https://github.com/antonioribeiro/google2fa.git",
+                "reference": "26c4c5cf30a2844ba121760fd7301f8ad240100b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/994ecccd8f3283ecf5ac33254543eb0ac946d525",
-                "reference": "994ecccd8f3283ecf5ac33254543eb0ac946d525",
+                "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/26c4c5cf30a2844ba121760fd7301f8ad240100b",
+                "reference": "26c4c5cf30a2844ba121760fd7301f8ad240100b",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9 || ^7.0 || ^8.0"
+                "paragonie/constant_time_encoding": "^1.0|^2.0",
+                "php": "^7.1|^8.0"
             },
             "require-dev": {
-                "bamarni/composer-bin-plugin": "^1.4.1",
-                "phpunit/phpunit": "^4.8.35 || ^5.7.27 || ^6.5.6 || ^7.0 || ^8.0 || ^9.0"
+                "phpstan/phpstan": "^0.12.18",
+                "phpunit/phpunit": "^7.5.15|^8.5|^9.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.7-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
-                    "PhpOption\\": "src/PhpOption/"
+                    "PragmaRX\\Google2FA\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "Apache-2.0"
+                "MIT"
             ],
             "authors": [
                 {
-                    "name": "Johannes M. Schmitt",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Graham Campbell",
-                    "email": "[email protected]"
+                    "name": "Antonio Carlos Ribeiro",
+                    "email": "[email protected]",
+                    "role": "Creator & Designer"
                 }
             ],
-            "description": "Option Type for PHP",
+            "description": "A One Time Password Authentication package, compatible with Google Authenticator.",
             "keywords": [
-                "language",
-                "option",
-                "php",
-                "type"
+                "2fa",
+                "Authentication",
+                "Two Factor Authentication",
+                "google2fa"
             ],
             "support": {
-                "issues": "https://github.com/schmittjoh/php-option/issues",
-                "source": "https://github.com/schmittjoh/php-option/tree/1.7.5"
+                "issues": "https://github.com/antonioribeiro/google2fa/issues",
+                "source": "https://github.com/antonioribeiro/google2fa/tree/8.0.0"
             },
-            "funding": [
-                {
-                    "url": "https://github.com/GrahamCampbell",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2020-07-20T17:29:33+00:00"
+            "time": "2020-04-05T10:47:18+00:00"
         },
         {
             "name": "predis/predis",
-            "version": "v1.1.7",
+            "version": "v1.1.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/predis/predis.git",
-                "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8"
+                "reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/predis/predis/zipball/b240daa106d4e02f0c5b7079b41e31ddf66fddf8",
-                "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8",
+                "url": "https://api.github.com/repos/predis/predis/zipball/a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e",
+                "reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/predis/predis/issues",
-                "source": "https://github.com/predis/predis/tree/v1.1.7"
+                "source": "https://github.com/predis/predis/tree/v1.1.10"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-04-04T19:34:46+00:00"
+            "time": "2022-01-05T17:46:08+00:00"
+        },
+        {
+            "name": "psr/cache",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/cache.git",
+                "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8",
+                "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for caching libraries",
+            "keywords": [
+                "cache",
+                "psr",
+                "psr-6"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/cache/tree/master"
+            },
+            "time": "2016-08-06T20:24:11+00:00"
         },
         {
             "name": "psr/container",
             },
             "time": "2021-03-05T17:36:06+00:00"
         },
+        {
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\EventDispatcher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Standard interfaces for event handling.",
+            "keywords": [
+                "events",
+                "psr",
+                "psr-14"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/event-dispatcher/issues",
+                "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+            },
+            "time": "2019-01-08T18:20:26+00:00"
+        },
         {
             "name": "psr/http-client",
             "version": "1.0.1",
             },
             "time": "2020-06-29T06:28:15+00:00"
         },
+        {
+            "name": "psr/http-factory",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-factory.git",
+                "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+                "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0.0",
+                "psr/http-message": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interfaces for PSR-7 HTTP message factories",
+            "keywords": [
+                "factory",
+                "http",
+                "message",
+                "psr",
+                "psr-17",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-factory/tree/master"
+            },
+            "time": "2019-04-30T12:38:16+00:00"
+        },
         {
             "name": "psr/http-message",
             "version": "1.0.1",
                 "simple-cache"
             ],
             "support": {
-                "source": "https://github.com/php-fig/simple-cache/tree/master"
+                "source": "https://github.com/php-fig/simple-cache/tree/master"
+            },
+            "time": "2017-10-23T01:57:42+00:00"
+        },
+        {
+            "name": "psy/psysh",
+            "version": "v0.11.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/bobthecow/psysh.git",
+                "reference": "570292577277f06f590635381a7f761a6cf4f026"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/bobthecow/psysh/zipball/570292577277f06f590635381a7f761a6cf4f026",
+                "reference": "570292577277f06f590635381a7f761a6cf4f026",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "nikic/php-parser": "^4.0 || ^3.1",
+                "php": "^8.0 || ^7.0.8",
+                "symfony/console": "^6.0 || ^5.0 || ^4.0 || ^3.4",
+                "symfony/var-dumper": "^6.0 || ^5.0 || ^4.0 || ^3.4"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.2",
+                "hoa/console": "3.17.05.02"
+            },
+            "suggest": {
+                "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.",
+                "ext-readline": "Enables support for arrow-key history navigation, and showing and manipulating command history.",
+                "hoa/console": "A pure PHP readline implementation. You'll want this if your PHP install doesn't already support readline or libedit."
+            },
+            "bin": [
+                "bin/psysh"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "0.11.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "Psy\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Justin Hileman",
+                    "email": "[email protected]",
+                    "homepage": "http://justinhileman.com"
+                }
+            ],
+            "description": "An interactive shell for modern PHP.",
+            "homepage": "http://psysh.org",
+            "keywords": [
+                "REPL",
+                "console",
+                "interactive",
+                "shell"
+            ],
+            "support": {
+                "issues": "https://github.com/bobthecow/psysh/issues",
+                "source": "https://github.com/bobthecow/psysh/tree/v0.11.1"
+            },
+            "time": "2022-01-03T13:58:38+00:00"
+        },
+        {
+            "name": "ralouphie/getallheaders",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ralouphie/getallheaders.git",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.1",
+                "phpunit/phpunit": "^5 || ^6.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/getallheaders.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ralph Khattar",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "A polyfill for getallheaders.",
+            "support": {
+                "issues": "https://github.com/ralouphie/getallheaders/issues",
+                "source": "https://github.com/ralouphie/getallheaders/tree/develop"
             },
-            "time": "2017-10-23T01:57:42+00:00"
+            "time": "2019-03-08T08:55:37+00:00"
         },
         {
-            "name": "ralouphie/getallheaders",
-            "version": "3.0.3",
+            "name": "ramsey/collection",
+            "version": "1.2.2",
             "source": {
                 "type": "git",
-                "url": "https://github.com/ralouphie/getallheaders.git",
-                "reference": "120b605dfeb996808c31b6477290a714d356e822"
+                "url": "https://github.com/ramsey/collection.git",
+                "reference": "cccc74ee5e328031b15640b51056ee8d3bb66c0a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
-                "reference": "120b605dfeb996808c31b6477290a714d356e822",
+                "url": "https://api.github.com/repos/ramsey/collection/zipball/cccc74ee5e328031b15640b51056ee8d3bb66c0a",
+                "reference": "cccc74ee5e328031b15640b51056ee8d3bb66c0a",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.6"
+                "php": "^7.3 || ^8",
+                "symfony/polyfill-php81": "^1.23"
             },
             "require-dev": {
-                "php-coveralls/php-coveralls": "^2.1",
-                "phpunit/phpunit": "^5 || ^6.5"
+                "captainhook/captainhook": "^5.3",
+                "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
+                "ergebnis/composer-normalize": "^2.6",
+                "fakerphp/faker": "^1.5",
+                "hamcrest/hamcrest-php": "^2",
+                "jangregor/phpstan-prophecy": "^0.8",
+                "mockery/mockery": "^1.3",
+                "phpspec/prophecy-phpunit": "^2.0",
+                "phpstan/extension-installer": "^1",
+                "phpstan/phpstan": "^0.12.32",
+                "phpstan/phpstan-mockery": "^0.12.5",
+                "phpstan/phpstan-phpunit": "^0.12.11",
+                "phpunit/phpunit": "^8.5 || ^9",
+                "psy/psysh": "^0.10.4",
+                "slevomat/coding-standard": "^6.3",
+                "squizlabs/php_codesniffer": "^3.5",
+                "vimeo/psalm": "^4.4"
             },
             "type": "library",
             "autoload": {
-                "files": [
-                    "src/getallheaders.php"
-                ]
+                "psr-4": {
+                    "Ramsey\\Collection\\": "src/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
             ],
             "authors": [
                 {
-                    "name": "Ralph Khattar",
-                    "email": "[email protected]"
+                    "name": "Ben Ramsey",
+                    "email": "[email protected]",
+                    "homepage": "https://benramsey.com"
                 }
             ],
-            "description": "A polyfill for getallheaders.",
+            "description": "A PHP library for representing and manipulating collections.",
+            "keywords": [
+                "array",
+                "collection",
+                "hash",
+                "map",
+                "queue",
+                "set"
+            ],
             "support": {
-                "issues": "https://github.com/ralouphie/getallheaders/issues",
-                "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+                "issues": "https://github.com/ramsey/collection/issues",
+                "source": "https://github.com/ramsey/collection/tree/1.2.2"
             },
-            "time": "2019-03-08T08:55:37+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/ramsey",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-10-10T03:01:02+00:00"
         },
         {
             "name": "ramsey/uuid",
-            "version": "3.9.3",
+            "version": "4.2.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/ramsey/uuid.git",
-                "reference": "7e1633a6964b48589b142d60542f9ed31bd37a92"
+                "reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ramsey/uuid/zipball/7e1633a6964b48589b142d60542f9ed31bd37a92",
-                "reference": "7e1633a6964b48589b142d60542f9ed31bd37a92",
+                "url": "https://api.github.com/repos/ramsey/uuid/zipball/fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df",
+                "reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df",
                 "shasum": ""
             },
             "require": {
+                "brick/math": "^0.8 || ^0.9",
                 "ext-json": "*",
-                "paragonie/random_compat": "^1 | ^2 | 9.99.99",
-                "php": "^5.4 | ^7 | ^8",
-                "symfony/polyfill-ctype": "^1.8"
+                "php": "^7.2 || ^8.0",
+                "ramsey/collection": "^1.0",
+                "symfony/polyfill-ctype": "^1.8",
+                "symfony/polyfill-php80": "^1.14"
             },
             "replace": {
                 "rhumsaa/uuid": "self.version"
             },
             "require-dev": {
-                "codeception/aspect-mock": "^1 | ^2",
-                "doctrine/annotations": "^1.2",
-                "goaop/framework": "1.0.0-alpha.2 | ^1 | ^2.1",
-                "jakub-onderka/php-parallel-lint": "^1",
-                "mockery/mockery": "^0.9.11 | ^1",
+                "captainhook/captainhook": "^5.10",
+                "captainhook/plugin-composer": "^5.3",
+                "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
+                "doctrine/annotations": "^1.8",
+                "ergebnis/composer-normalize": "^2.15",
+                "mockery/mockery": "^1.3",
                 "moontoast/math": "^1.1",
                 "paragonie/random-lib": "^2",
-                "php-mock/php-mock-phpunit": "^0.3 | ^1.1",
-                "phpunit/phpunit": "^4.8 | ^5.4 | ^6.5",
-                "squizlabs/php_codesniffer": "^3.5"
+                "php-mock/php-mock": "^2.2",
+                "php-mock/php-mock-mockery": "^1.3",
+                "php-parallel-lint/php-parallel-lint": "^1.1",
+                "phpbench/phpbench": "^1.0",
+                "phpstan/extension-installer": "^1.0",
+                "phpstan/phpstan": "^0.12",
+                "phpstan/phpstan-mockery": "^0.12",
+                "phpstan/phpstan-phpunit": "^0.12",
+                "phpunit/phpunit": "^8.5 || ^9",
+                "slevomat/coding-standard": "^7.0",
+                "squizlabs/php_codesniffer": "^3.5",
+                "vimeo/psalm": "^4.9"
             },
             "suggest": {
-                "ext-ctype": "Provides support for PHP Ctype functions",
-                "ext-libsodium": "Provides the PECL libsodium extension for use with the SodiumRandomGenerator",
-                "ext-openssl": "Provides the OpenSSL extension for use with the OpenSslGenerator",
-                "ext-uuid": "Provides the PECL UUID extension for use with the PeclUuidTimeGenerator and PeclUuidRandomGenerator",
-                "moontoast/math": "Provides support for converting UUID to 128-bit integer (in string form).",
+                "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
+                "ext-ctype": "Enables faster processing of character classification using ctype functions.",
+                "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
+                "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
                 "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
-                "ramsey/uuid-console": "A console application for generating UUIDs with ramsey/uuid",
                 "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.x-dev"
+                    "dev-main": "4.x-dev"
+                },
+                "captainhook": {
+                    "force-install": true
                 }
             },
             "autoload": {
             "license": [
                 "MIT"
             ],
-            "authors": [
-                {
-                    "name": "Ben Ramsey",
-                    "email": "[email protected]",
-                    "homepage": "https://benramsey.com"
-                },
-                {
-                    "name": "Marijn Huizendveld",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Thibaud Fabre",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "Formerly rhumsaa/uuid. A PHP 5.4+ library for generating RFC 4122 version 1, 3, 4, and 5 universally unique identifiers (UUID).",
-            "homepage": "https://github.com/ramsey/uuid",
+            "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
             "keywords": [
                 "guid",
                 "identifier",
             ],
             "support": {
                 "issues": "https://github.com/ramsey/uuid/issues",
-                "rss": "https://github.com/ramsey/uuid/releases.atom",
-                "source": "https://github.com/ramsey/uuid",
-                "wiki": "https://github.com/ramsey/uuid/wiki"
+                "source": "https://github.com/ramsey/uuid/tree/4.2.3"
             },
-            "time": "2020-02-21T04:36:14+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/ramsey",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-09-25T23:10:38+00:00"
         },
         {
             "name": "robrichards/xmlseclibs",
         },
         {
             "name": "sabberworm/php-css-parser",
-            "version": "8.3.1",
+            "version": "8.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sabberworm/PHP-CSS-Parser.git",
-                "reference": "d217848e1396ef962fb1997cf3e2421acba7f796"
+                "reference": "e41d2140031d533348b2192a83f02d8dd8a71d30"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sabberworm/PHP-CSS-Parser/zipball/d217848e1396ef962fb1997cf3e2421acba7f796",
-                "reference": "d217848e1396ef962fb1997cf3e2421acba7f796",
+                "url": "https://api.github.com/repos/sabberworm/PHP-CSS-Parser/zipball/e41d2140031d533348b2192a83f02d8dd8a71d30",
+                "reference": "e41d2140031d533348b2192a83f02d8dd8a71d30",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.2"
+                "ext-iconv": "*",
+                "php": ">=5.6.20"
             },
             "require-dev": {
                 "codacy/coverage": "^1.4",
-                "phpunit/phpunit": "~4.8"
+                "phpunit/phpunit": "^4.8.36"
+            },
+            "suggest": {
+                "ext-mbstring": "for parsing UTF-8 CSS"
             },
             "type": "library",
             "autoload": {
-                "psr-0": {
-                    "Sabberworm\\CSS": "lib/"
+                "psr-4": {
+                    "Sabberworm\\CSS\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 }
             ],
             "description": "Parser for CSS Files written in PHP",
-            "homepage": "http://www.sabberworm.com/blog/2010/6/10/php-css-parser",
+            "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
             "keywords": [
                 "css",
                 "parser",
             ],
             "support": {
                 "issues": "https://github.com/sabberworm/PHP-CSS-Parser/issues",
-                "source": "https://github.com/sabberworm/PHP-CSS-Parser/tree/8.3.1"
-            },
-            "time": "2020-06-01T09:10:00+00:00"
-        },
-        {
-            "name": "scrivo/highlight.php",
-            "version": "v9.18.1.6",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/scrivo/highlight.php.git",
-                "reference": "44a3d4136edb5ad8551590bf90f437db80b2d466"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/44a3d4136edb5ad8551590bf90f437db80b2d466",
-                "reference": "44a3d4136edb5ad8551590bf90f437db80b2d466",
-                "shasum": ""
-            },
-            "require": {
-                "ext-json": "*",
-                "ext-mbstring": "*",
-                "php": ">=5.4"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.8|^5.7",
-                "sabberworm/php-css-parser": "^8.3",
-                "symfony/finder": "^2.8|^3.4",
-                "symfony/var-dumper": "^2.8|^3.4"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-0": {
-                    "Highlight\\": "",
-                    "HighlightUtilities\\": ""
-                },
-                "files": [
-                    "HighlightUtilities/functions.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Geert Bergman",
-                    "homepage": "http://www.scrivo.org/",
-                    "role": "Project Author"
-                },
-                {
-                    "name": "Vladimir Jimenez",
-                    "homepage": "https://allejo.io",
-                    "role": "Maintainer"
-                },
-                {
-                    "name": "Martin Folkers",
-                    "homepage": "https://twobrain.io",
-                    "role": "Contributor"
-                }
-            ],
-            "description": "Server side syntax highlighter that supports 185 languages. It's a PHP port of highlight.js",
-            "keywords": [
-                "code",
-                "highlight",
-                "highlight.js",
-                "highlight.php",
-                "syntax"
-            ],
-            "support": {
-                "issues": "https://github.com/scrivo/highlight.php/issues",
-                "source": "https://github.com/scrivo/highlight.php"
+                "source": "https://github.com/sabberworm/PHP-CSS-Parser/tree/8.4.0"
             },
-            "funding": [
-                {
-                    "url": "https://github.com/allejo",
-                    "type": "github"
-                }
-            ],
-            "time": "2020-12-22T19:20:29+00:00"
+            "time": "2021-12-11T13:40:54+00:00"
         },
         {
             "name": "socialiteproviders/discord",
         },
         {
             "name": "socialiteproviders/manager",
-            "version": "4.0.1",
+            "version": "v4.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Manager.git",
-                "reference": "0f5e82af0404df0080bdc5c105cef936c1711524"
+                "reference": "4e63afbd26dc45ff263591de2a0970436a6a0bf9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/0f5e82af0404df0080bdc5c105cef936c1711524",
-                "reference": "0f5e82af0404df0080bdc5c105cef936c1711524",
+                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/4e63afbd26dc45ff263591de2a0970436a6a0bf9",
+                "reference": "4e63afbd26dc45ff263591de2a0970436a6a0bf9",
                 "shasum": ""
             },
             "require": {
-                "illuminate/support": "^6.0|^7.0|^8.0",
-                "laravel/socialite": "~4.0|~5.0",
+                "illuminate/support": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "laravel/socialite": "~4.0 || ~5.0",
                 "php": "^7.2 || ^8.0"
             },
             "require-dev": {
                 "mockery/mockery": "^1.2",
-                "phpunit/phpunit": "^9.0"
+                "phpunit/phpunit": "^6.0 || ^9.0"
             },
             "type": "library",
             "extra": {
                 }
             ],
             "description": "Easily add new or override built-in providers in Laravel Socialite.",
-            "homepage": "https://socialiteproviders.com/",
+            "homepage": "https://socialiteproviders.com",
+            "keywords": [
+                "laravel",
+                "manager",
+                "oauth",
+                "providers",
+                "socialite"
+            ],
             "support": {
-                "issues": "https://github.com/SocialiteProviders/Manager/issues",
-                "source": "https://github.com/SocialiteProviders/Manager/tree/4.0.1"
+                "issues": "https://github.com/socialiteproviders/manager/issues",
+                "source": "https://github.com/socialiteproviders/manager"
             },
-            "time": "2020-12-01T23:09:06+00:00"
+            "time": "2022-01-23T22:40:23+00:00"
         },
         {
             "name": "socialiteproviders/microsoft-azure",
-            "version": "4.2.0",
+            "version": "5.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Microsoft-Azure.git",
-                "reference": "7808764f777a01df88be9ca6b14d683e50aaf88a"
+                "reference": "9b23e02ff711de42e513aa55f768a4f1c67c0e41"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/7808764f777a01df88be9ca6b14d683e50aaf88a",
-                "reference": "7808764f777a01df88be9ca6b14d683e50aaf88a",
+                "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/9b23e02ff711de42e513aa55f768a4f1c67c0e41",
+                "reference": "9b23e02ff711de42e513aa55f768a4f1c67c0e41",
                 "shasum": ""
             },
             "require": {
                 }
             ],
             "description": "Microsoft Azure OAuth2 Provider for Laravel Socialite",
+            "keywords": [
+                "azure",
+                "laravel",
+                "microsoft",
+                "oauth",
+                "provider",
+                "socialite"
+            ],
             "support": {
-                "source": "https://github.com/SocialiteProviders/Microsoft-Azure/tree/4.2.0"
+                "docs": "https://socialiteproviders.com/microsoft-azure",
+                "issues": "https://github.com/socialiteproviders/providers/issues",
+                "source": "https://github.com/socialiteproviders/providers"
             },
-            "time": "2020-12-01T23:10:59+00:00"
+            "time": "2021-10-07T22:21:59+00:00"
         },
         {
             "name": "socialiteproviders/okta",
         },
         {
             "name": "ssddanbrown/htmldiff",
-            "version": "v1.0.1",
+            "version": "v1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/ssddanbrown/HtmlDiff.git",
-                "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0"
+                "reference": "58f81857c02b50b199273edb4cc339876b5a4038"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/f60d5cc278b60305ab980a6665f46117c5b589c0",
-                "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0",
+                "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/58f81857c02b50b199273edb4cc339876b5a4038",
+                "reference": "58f81857c02b50b199273edb4cc339876b5a4038",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2"
             },
             "require-dev": {
-                "phpunit/phpunit": "^8.5|^9.4.3"
+                "phpunit/phpunit": "^8.5|^9.5.9",
+                "vimeo/psalm": "^4.10"
             },
             "type": "library",
             "autoload": {
             "homepage": "https://github.com/ssddanbrown/htmldiff",
             "support": {
                 "issues": "https://github.com/ssddanbrown/HtmlDiff/issues",
-                "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.1"
+                "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.2"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-01-24T18:51:30+00:00"
+            "time": "2022-01-24T20:12:20+00:00"
         },
         {
             "name": "swiftmailer/swiftmailer",
-            "version": "v6.2.7",
+            "version": "v6.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/swiftmailer/swiftmailer.git",
-                "reference": "15f7faf8508e04471f666633addacf54c0ab5933"
+                "reference": "8a5d5072dca8f48460fce2f4131fcc495eec654c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/15f7faf8508e04471f666633addacf54c0ab5933",
-                "reference": "15f7faf8508e04471f666633addacf54c0ab5933",
+                "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/8a5d5072dca8f48460fce2f4131fcc495eec654c",
+                "reference": "8a5d5072dca8f48460fce2f4131fcc495eec654c",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "mockery/mockery": "^1.0",
-                "symfony/phpunit-bridge": "^4.4|^5.0"
+                "symfony/phpunit-bridge": "^4.4|^5.4"
             },
             "suggest": {
                 "ext-intl": "Needed to support internationalized email addresses"
             ],
             "support": {
                 "issues": "https://github.com/swiftmailer/swiftmailer/issues",
-                "source": "https://github.com/swiftmailer/swiftmailer/tree/v6.2.7"
+                "source": "https://github.com/swiftmailer/swiftmailer/tree/v6.3.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-03-09T12:30:35+00:00"
+            "abandoned": "symfony/mailer",
+            "time": "2021-10-18T15:26:12+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "36bbd079b69b94bcc9c9c9e1e37ca3b1e7971625"
+                "reference": "a2c6b7ced2eb7799a35375fb9022519282b5405e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/36bbd079b69b94bcc9c9c9e1e37ca3b1e7971625",
-                "reference": "36bbd079b69b94bcc9c9c9e1e37ca3b1e7971625",
+                "url": "https://api.github.com/repos/symfony/console/zipball/a2c6b7ced2eb7799a35375fb9022519282b5405e",
+                "reference": "a2c6b7ced2eb7799a35375fb9022519282b5405e",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
                 "symfony/polyfill-mbstring": "~1.0",
-                "symfony/polyfill-php73": "^1.8",
-                "symfony/polyfill-php80": "^1.15",
-                "symfony/service-contracts": "^1.1|^2"
+                "symfony/polyfill-php73": "^1.9",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/service-contracts": "^1.1|^2|^3",
+                "symfony/string": "^5.1|^6.0"
             },
             "conflict": {
-                "symfony/dependency-injection": "<3.4",
-                "symfony/event-dispatcher": "<4.3|>=5",
+                "psr/log": ">=3",
+                "symfony/dependency-injection": "<4.4",
+                "symfony/dotenv": "<5.1",
+                "symfony/event-dispatcher": "<4.4",
                 "symfony/lock": "<4.4",
-                "symfony/process": "<3.3"
+                "symfony/process": "<4.4"
             },
             "provide": {
-                "psr/log-implementation": "1.0"
+                "psr/log-implementation": "1.0|2.0"
             },
             "require-dev": {
-                "psr/log": "~1.0",
-                "symfony/config": "^3.4|^4.0|^5.0",
-                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
-                "symfony/event-dispatcher": "^4.3",
-                "symfony/lock": "^4.4|^5.0",
-                "symfony/process": "^3.4|^4.0|^5.0",
-                "symfony/var-dumper": "^4.3|^5.0"
+                "psr/log": "^1|^2",
+                "symfony/config": "^4.4|^5.0|^6.0",
+                "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+                "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
+                "symfony/lock": "^4.4|^5.0|^6.0",
+                "symfony/process": "^4.4|^5.0|^6.0",
+                "symfony/var-dumper": "^4.4|^5.0|^6.0"
             },
             "suggest": {
                 "psr/log": "For using the console logger",
             ],
             "description": "Eases the creation of beautiful and testable command line interfaces",
             "homepage": "https://symfony.com",
+            "keywords": [
+                "cli",
+                "command line",
+                "console",
+                "terminal"
+            ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v4.4.22"
+                "source": "https://github.com/symfony/console/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-16T17:32:19+00:00"
+            "time": "2021-12-20T16:11:12+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "01c77324d1d47efbfd7891f62a7c256c69330115"
+                "reference": "cfcbee910e159df402603502fe387e8b677c22fd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/01c77324d1d47efbfd7891f62a7c256c69330115",
-                "reference": "01c77324d1d47efbfd7891f62a7c256c69330115",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/cfcbee910e159df402603502fe387e8b677c22fd",
+                "reference": "cfcbee910e159df402603502fe387e8b677c22fd",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3"
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
             "autoload": {
             "description": "Converts CSS selectors to XPath expressions",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/css-selector/tree/v4.4.22"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2021-04-07T15:47:03+00:00"
-        },
-        {
-            "name": "symfony/debug",
-            "version": "v4.4.22",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/debug.git",
-                "reference": "45b2136377cca5f10af858968d6079a482bca473"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/45b2136377cca5f10af858968d6079a482bca473",
-                "reference": "45b2136377cca5f10af858968d6079a482bca473",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1.3",
-                "psr/log": "~1.0",
-                "symfony/polyfill-php80": "^1.15"
-            },
-            "conflict": {
-                "symfony/http-kernel": "<3.4"
-            },
-            "require-dev": {
-                "symfony/http-kernel": "^3.4|^4.0|^5.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Debug\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Provides tools to ease debugging PHP code",
-            "homepage": "https://symfony.com",
-            "support": {
-                "source": "https://github.com/symfony/debug/tree/v4.4.22"
+                "source": "https://github.com/symfony/css-selector/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-02T07:50:12+00:00"
+            "time": "2021-12-16T21:58:21+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
-            "version": "v2.4.0",
+            "version": "v2.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/deprecation-contracts.git",
-                "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627"
+                "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627",
-                "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/6f981ee24cf69ee7ce9736146d1c57c2780598a8",
+                "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "2.4-dev"
+                    "dev-main": "2.5-dev"
                 },
                 "thanks": {
                     "name": "symfony/contracts",
             "description": "A generic function and convention to trigger deprecation notices",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0"
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-03-23T23:28:01+00:00"
+            "time": "2021-07-12T14:48:14+00:00"
         },
         {
             "name": "symfony/error-handler",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "76603a8df8e001436df80758eb03a8baa5324175"
+                "reference": "e0c0dd0f9d4120a20158fc9aec2367d07d38bc56"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/76603a8df8e001436df80758eb03a8baa5324175",
-                "reference": "76603a8df8e001436df80758eb03a8baa5324175",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/e0c0dd0f9d4120a20158fc9aec2367d07d38bc56",
+                "reference": "e0c0dd0f9d4120a20158fc9aec2367d07d38bc56",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
-                "psr/log": "~1.0",
-                "symfony/debug": "^4.4.5",
-                "symfony/polyfill-php80": "^1.15",
-                "symfony/var-dumper": "^4.4|^5.0"
+                "php": ">=7.2.5",
+                "psr/log": "^1|^2|^3",
+                "symfony/var-dumper": "^4.4|^5.0|^6.0"
             },
             "require-dev": {
-                "symfony/http-kernel": "^4.4|^5.0",
-                "symfony/serializer": "^4.4|^5.0"
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/http-kernel": "^4.4|^5.0|^6.0",
+                "symfony/serializer": "^4.4|^5.0|^6.0"
             },
+            "bin": [
+                "Resources/bin/patch-type-declarations"
+            ],
             "type": "library",
             "autoload": {
                 "psr-4": {
             "description": "Provides tools to manage errors and ease debugging PHP code",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/error-handler/tree/v4.4.22"
+                "source": "https://github.com/symfony/error-handler/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-02T07:50:12+00:00"
+            "time": "2021-12-19T20:02:00+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v4.4.20",
+            "version": "v5.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c"
+                "reference": "27d39ae126352b9fa3be5e196ccf4617897be3eb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c352647244bd376bf7d31efbd5401f13f50dad0c",
-                "reference": "c352647244bd376bf7d31efbd5401f13f50dad0c",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/27d39ae126352b9fa3be5e196ccf4617897be3eb",
+                "reference": "27d39ae126352b9fa3be5e196ccf4617897be3eb",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
-                "symfony/event-dispatcher-contracts": "^1.1"
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/event-dispatcher-contracts": "^2|^3",
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
-                "symfony/dependency-injection": "<3.4"
+                "symfony/dependency-injection": "<4.4"
             },
             "provide": {
                 "psr/event-dispatcher-implementation": "1.0",
-                "symfony/event-dispatcher-implementation": "1.1"
+                "symfony/event-dispatcher-implementation": "2.0"
             },
             "require-dev": {
-                "psr/log": "~1.0",
-                "symfony/config": "^3.4|^4.0|^5.0",
-                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
-                "symfony/error-handler": "~3.4|~4.4",
-                "symfony/expression-language": "^3.4|^4.0|^5.0",
-                "symfony/http-foundation": "^3.4|^4.0|^5.0",
-                "symfony/service-contracts": "^1.1|^2",
-                "symfony/stopwatch": "^3.4|^4.0|^5.0"
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^4.4|^5.0|^6.0",
+                "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+                "symfony/error-handler": "^4.4|^5.0|^6.0",
+                "symfony/expression-language": "^4.4|^5.0|^6.0",
+                "symfony/http-foundation": "^4.4|^5.0|^6.0",
+                "symfony/service-contracts": "^1.1|^2|^3",
+                "symfony/stopwatch": "^4.4|^5.0|^6.0"
             },
             "suggest": {
                 "symfony/dependency-injection": "",
             "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/v4.4.20"
+                "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-27T09:09:26+00:00"
+            "time": "2021-11-23T10:19:22+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
-            "version": "v1.1.9",
+            "version": "v2.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "84e23fdcd2517bf37aecbd16967e83f0caee25a7"
+                "reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/84e23fdcd2517bf37aecbd16967e83f0caee25a7",
-                "reference": "84e23fdcd2517bf37aecbd16967e83f0caee25a7",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/66bea3b09be61613cd3b4043a65a8ec48cfa6d2a",
+                "reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3"
+                "php": ">=7.2.5",
+                "psr/event-dispatcher": "^1"
             },
             "suggest": {
-                "psr/event-dispatcher": "",
                 "symfony/event-dispatcher-implementation": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.1-dev"
+                    "dev-main": "2.5-dev"
                 },
                 "thanks": {
                     "name": "symfony/contracts",
             "homepage": "https://symfony.com",
             "keywords": [
                 "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
-            ],
-            "support": {
-                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v1.1.9"
-            },
-            "funding": [
-                {
-                    "url": "https://symfony.com/sponsor",
-                    "type": "custom"
-                },
-                {
-                    "url": "https://github.com/fabpot",
-                    "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
-                    "type": "tidelift"
-                }
-            ],
-            "time": "2020-07-06T13:19:58+00:00"
-        },
-        {
-            "name": "symfony/finder",
-            "version": "v4.4.20",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/finder.git",
-                "reference": "2543795ab1570df588b9bbd31e1a2bd7037b94f6"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/2543795ab1570df588b9bbd31e1a2bd7037b94f6",
-                "reference": "2543795ab1570df588b9bbd31e1a2bd7037b94f6",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1.3"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Finder\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
             ],
-            "description": "Finds files and directories via an intuitive fluent interface",
-            "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/finder/tree/v4.4.20"
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-02-12T10:48:09+00:00"
+            "time": "2021-07-12T14:48:14+00:00"
         },
         {
-            "name": "symfony/http-client-contracts",
-            "version": "v2.4.0",
+            "name": "symfony/finder",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/http-client-contracts.git",
-                "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4"
+                "url": "https://github.com/symfony/finder.git",
+                "reference": "e77046c252be48c48a40816187ed527703c8f76c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/7e82f6084d7cae521a75ef2cb5c9457bbda785f4",
-                "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/e77046c252be48c48a40816187ed527703c8f76c",
+                "reference": "e77046c252be48c48a40816187ed527703c8f76c",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.2.5"
-            },
-            "suggest": {
-                "symfony/http-client-implementation": ""
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "2.4-dev"
-                },
-                "thanks": {
-                    "name": "symfony/contracts",
-                    "url": "https://github.com/symfony/contracts"
-                }
-            },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Contracts\\HttpClient\\": ""
-                }
+                    "Symfony\\Component\\Finder\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
             ],
             "authors": [
                 {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Generic abstractions related to HTTP clients",
+            "description": "Finds files and directories via an intuitive fluent interface",
             "homepage": "https://symfony.com",
-            "keywords": [
-                "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
-            ],
             "support": {
-                "source": "https://github.com/symfony/http-client-contracts/tree/v2.4.0"
+                "source": "https://github.com/symfony/finder/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-11T23:07:08+00:00"
+            "time": "2021-12-15T11:06:13+00:00"
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "1a6f87ef99d05b1bf5c865b4ef7992263e1cb081"
+                "reference": "ce952af52877eaf3eab5d0c08cc0ea865ed37313"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1a6f87ef99d05b1bf5c865b4ef7992263e1cb081",
-                "reference": "1a6f87ef99d05b1bf5c865b4ef7992263e1cb081",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ce952af52877eaf3eab5d0c08cc0ea865ed37313",
+                "reference": "ce952af52877eaf3eab5d0c08cc0ea865ed37313",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
-                "symfony/mime": "^4.3|^5.0",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
                 "symfony/polyfill-mbstring": "~1.1",
-                "symfony/polyfill-php80": "^1.15"
+                "symfony/polyfill-php80": "^1.16"
             },
             "require-dev": {
                 "predis/predis": "~1.0",
-                "symfony/expression-language": "^3.4|^4.0|^5.0"
+                "symfony/cache": "^4.4|^5.0|^6.0",
+                "symfony/expression-language": "^4.4|^5.0|^6.0",
+                "symfony/mime": "^4.4|^5.0|^6.0"
+            },
+            "suggest": {
+                "symfony/mime": "To use the file extension guesser"
             },
             "type": "library",
             "autoload": {
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v4.4.22"
+                "source": "https://github.com/symfony/http-foundation/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-30T12:05:50+00:00"
+            "time": "2021-12-28T17:15:56+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "cd2e325fc34a4a5bbec91eecf69dda8ee8c5ea4f"
+                "reference": "35b7e9868953e0d1df84320bb063543369e43ef5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cd2e325fc34a4a5bbec91eecf69dda8ee8c5ea4f",
-                "reference": "cd2e325fc34a4a5bbec91eecf69dda8ee8c5ea4f",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/35b7e9868953e0d1df84320bb063543369e43ef5",
+                "reference": "35b7e9868953e0d1df84320bb063543369e43ef5",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
-                "psr/log": "~1.0",
-                "symfony/error-handler": "^4.4",
-                "symfony/event-dispatcher": "^4.4",
-                "symfony/http-client-contracts": "^1.1|^2",
-                "symfony/http-foundation": "^4.4|^5.0",
+                "php": ">=7.2.5",
+                "psr/log": "^1|^2",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/error-handler": "^4.4|^5.0|^6.0",
+                "symfony/event-dispatcher": "^5.0|^6.0",
+                "symfony/http-foundation": "^5.3.7|^6.0",
                 "symfony/polyfill-ctype": "^1.8",
                 "symfony/polyfill-php73": "^1.9",
-                "symfony/polyfill-php80": "^1.15"
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
-                "symfony/browser-kit": "<4.3",
-                "symfony/config": "<3.4",
-                "symfony/console": ">=5",
-                "symfony/dependency-injection": "<4.3",
-                "symfony/translation": "<4.2",
-                "twig/twig": "<1.43|<2.13,>=2"
+                "symfony/browser-kit": "<5.4",
+                "symfony/cache": "<5.0",
+                "symfony/config": "<5.0",
+                "symfony/console": "<4.4",
+                "symfony/dependency-injection": "<5.3",
+                "symfony/doctrine-bridge": "<5.0",
+                "symfony/form": "<5.0",
+                "symfony/http-client": "<5.0",
+                "symfony/mailer": "<5.0",
+                "symfony/messenger": "<5.0",
+                "symfony/translation": "<5.0",
+                "symfony/twig-bridge": "<5.0",
+                "symfony/validator": "<5.0",
+                "twig/twig": "<2.13"
             },
             "provide": {
-                "psr/log-implementation": "1.0"
+                "psr/log-implementation": "1.0|2.0"
             },
             "require-dev": {
                 "psr/cache": "^1.0|^2.0|^3.0",
-                "symfony/browser-kit": "^4.3|^5.0",
-                "symfony/config": "^3.4|^4.0|^5.0",
-                "symfony/console": "^3.4|^4.0",
-                "symfony/css-selector": "^3.4|^4.0|^5.0",
-                "symfony/dependency-injection": "^4.3|^5.0",
-                "symfony/dom-crawler": "^3.4|^4.0|^5.0",
-                "symfony/expression-language": "^3.4|^4.0|^5.0",
-                "symfony/finder": "^3.4|^4.0|^5.0",
-                "symfony/process": "^3.4|^4.0|^5.0",
-                "symfony/routing": "^3.4|^4.0|^5.0",
-                "symfony/stopwatch": "^3.4|^4.0|^5.0",
-                "symfony/templating": "^3.4|^4.0|^5.0",
-                "symfony/translation": "^4.2|^5.0",
-                "symfony/translation-contracts": "^1.1|^2",
-                "twig/twig": "^1.43|^2.13|^3.0.4"
+                "symfony/browser-kit": "^5.4|^6.0",
+                "symfony/config": "^5.0|^6.0",
+                "symfony/console": "^4.4|^5.0|^6.0",
+                "symfony/css-selector": "^4.4|^5.0|^6.0",
+                "symfony/dependency-injection": "^5.3|^6.0",
+                "symfony/dom-crawler": "^4.4|^5.0|^6.0",
+                "symfony/expression-language": "^4.4|^5.0|^6.0",
+                "symfony/finder": "^4.4|^5.0|^6.0",
+                "symfony/http-client-contracts": "^1.1|^2|^3",
+                "symfony/process": "^4.4|^5.0|^6.0",
+                "symfony/routing": "^4.4|^5.0|^6.0",
+                "symfony/stopwatch": "^4.4|^5.0|^6.0",
+                "symfony/translation": "^4.4|^5.0|^6.0",
+                "symfony/translation-contracts": "^1.1|^2|^3",
+                "twig/twig": "^2.13|^3.0.4"
             },
             "suggest": {
                 "symfony/browser-kit": "",
             "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/v4.4.22"
+                "source": "https://github.com/symfony/http-kernel/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-05-01T14:38:48+00:00"
+            "time": "2021-12-29T13:20:26+00:00"
         },
         {
             "name": "symfony/mime",
-            "version": "v5.2.7",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/mime.git",
-                "reference": "7af452bf51c46f18da00feb32e1ad36db9426515"
+                "reference": "1bfd938cf9562822c05c4d00e8f92134d3c8e42d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/mime/zipball/7af452bf51c46f18da00feb32e1ad36db9426515",
-                "reference": "7af452bf51c46f18da00feb32e1ad36db9426515",
+                "url": "https://api.github.com/repos/symfony/mime/zipball/1bfd938cf9562822c05c4d00e8f92134d3c8e42d",
+                "reference": "1bfd938cf9562822c05c4d00e8f92134d3c8e42d",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2.5",
-                "symfony/deprecation-contracts": "^2.1",
+                "symfony/deprecation-contracts": "^2.1|^3",
                 "symfony/polyfill-intl-idn": "^1.10",
                 "symfony/polyfill-mbstring": "^1.0",
-                "symfony/polyfill-php80": "^1.15"
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
                 "egulias/email-validator": "~3.0.0",
             "require-dev": {
                 "egulias/email-validator": "^2.1.10|^3.1",
                 "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
-                "symfony/dependency-injection": "^4.4|^5.0",
-                "symfony/property-access": "^4.4|^5.1",
-                "symfony/property-info": "^4.4|^5.1",
-                "symfony/serializer": "^5.2"
+                "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+                "symfony/property-access": "^4.4|^5.1|^6.0",
+                "symfony/property-info": "^4.4|^5.1|^6.0",
+                "symfony/serializer": "^5.2|^6.0"
             },
             "type": "library",
             "autoload": {
                 "mime-type"
             ],
             "support": {
-                "source": "https://github.com/symfony/mime/tree/v5.2.7"
+                "source": "https://github.com/symfony/mime/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-29T20:47:09+00:00"
+            "time": "2021-12-28T17:15:56+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "c6c942b1ac76c82448322025e084cadc56048b4e"
+                "reference": "30885182c981ab175d4d034db0f6f469898070ab"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e",
-                "reference": "c6c942b1ac76c82448322025e084cadc56048b4e",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/30885182c981ab175d4d034db0f6f469898070ab",
+                "reference": "30885182c981ab175d4d034db0f6f469898070ab",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1"
             },
+            "provide": {
+                "ext-ctype": "*"
+            },
             "suggest": {
                 "ext-ctype": "For best performance"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "portable"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-07T16:49:33+00:00"
+            "time": "2021-10-20T20:35:02+00:00"
         },
         {
             "name": "symfony/polyfill-iconv",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-iconv.git",
-                "reference": "06fb361659649bcfd6a208a0f1fcaf4e827ad342"
+                "reference": "f1aed619e28cb077fc83fac8c4c0383578356e40"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/06fb361659649bcfd6a208a0f1fcaf4e827ad342",
-                "reference": "06fb361659649bcfd6a208a0f1fcaf4e827ad342",
+                "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/f1aed619e28cb077fc83fac8c4c0383578356e40",
+                "reference": "f1aed619e28cb077fc83fac8c4c0383578356e40",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1"
             },
+            "provide": {
+                "ext-iconv": "*"
+            },
             "suggest": {
                 "ext-iconv": "For best performance"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-iconv/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-iconv/tree/v1.24.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-01-04T09:04:05+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-grapheme",
+            "version": "v1.24.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+                "reference": "81b86b50cf841a64252b439e738e97f4a34e2783"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/81b86b50cf841a64252b439e738e97f4a34e2783",
+                "reference": "81b86b50cf841a64252b439e738e97f4a34e2783",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's grapheme_* functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "grapheme",
+                "intl",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-22T09:19:47+00:00"
+            "time": "2021-11-23T21:10:46+00:00"
         },
         {
             "name": "symfony/polyfill-intl-idn",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-idn.git",
-                "reference": "2d63434d922daf7da8dd863e7907e67ee3031483"
+                "reference": "749045c69efb97c70d25d7463abba812e91f3a44"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/2d63434d922daf7da8dd863e7907e67ee3031483",
-                "reference": "2d63434d922daf7da8dd863e7907e67ee3031483",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/749045c69efb97c70d25d7463abba812e91f3a44",
+                "reference": "749045c69efb97c70d25d7463abba812e91f3a44",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-22T09:19:47+00:00"
+            "time": "2021-09-14T14:02:44+00:00"
         },
         {
             "name": "symfony/polyfill-intl-normalizer",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
-                "reference": "43a0283138253ed1d48d352ab6d0bdb3f809f248"
+                "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/43a0283138253ed1d48d352ab6d0bdb3f809f248",
-                "reference": "43a0283138253ed1d48d352ab6d0bdb3f809f248",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
+                "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-22T09:19:47+00:00"
+            "time": "2021-02-19T12:13:01+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "5232de97ee3b75b0360528dae24e73db49566ab1"
+                "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/5232de97ee3b75b0360528dae24e73db49566ab1",
-                "reference": "5232de97ee3b75b0360528dae24e73db49566ab1",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/0abb51d2f102e00a4eefcf46ba7fec406d245825",
+                "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1"
             },
+            "provide": {
+                "ext-mbstring": "*"
+            },
             "suggest": {
                 "ext-mbstring": "For best performance"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-22T09:19:47+00:00"
+            "time": "2021-11-30T18:21:41+00:00"
         },
         {
             "name": "symfony/polyfill-php72",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php72.git",
-                "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9"
+                "reference": "9a142215a36a3888e30d0a9eeea9766764e96976"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
-                "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976",
+                "reference": "9a142215a36a3888e30d0a9eeea9766764e96976",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php72/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-php72/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-07T16:49:33+00:00"
+            "time": "2021-05-27T09:17:38+00:00"
         },
         {
             "name": "symfony/polyfill-php73",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php73.git",
-                "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2"
+                "reference": "cc5db0e22b3cb4111010e48785a97f670b350ca5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
-                "reference": "a678b42e92f86eca04b7fa4c0f6f19d097fb69e2",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/cc5db0e22b3cb4111010e48785a97f670b350ca5",
+                "reference": "cc5db0e22b3cb4111010e48785a97f670b350ca5",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php73/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-php73/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-07T16:49:33+00:00"
+            "time": "2021-06-05T21:20:04+00:00"
         },
         {
             "name": "symfony/polyfill-php80",
-            "version": "v1.22.1",
+            "version": "v1.24.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-php80.git",
-                "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91"
+                "reference": "57b712b08eddb97c762a8caa32c84e037892d2e9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dc3063ba22c2a1fd2f45ed856374d79114998f91",
-                "reference": "dc3063ba22c2a1fd2f45ed856374d79114998f91",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/57b712b08eddb97c762a8caa32c84e037892d2e9",
+                "reference": "57b712b08eddb97c762a8caa32c84e037892d2e9",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "1.22-dev"
+                    "dev-main": "1.23-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "shim"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-php80/tree/v1.22.1"
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.24.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-09-13T13:58:33+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php81",
+            "version": "v1.24.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php81.git",
+                "reference": "5de4ba2d41b15f9bd0e19b2ab9674135813ec98f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/5de4ba2d41b15f9bd0e19b2ab9674135813ec98f",
+                "reference": "5de4ba2d41b15f9bd0e19b2ab9674135813ec98f",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php81\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php81/tree/v1.24.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-07T16:49:33+00:00"
+            "time": "2021-09-13T13:58:11+00:00"
         },
         {
             "name": "symfony/process",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "f5481b22729d465acb1cea3455fc04ce84b0148b"
+                "reference": "2b3ba8722c4aaf3e88011be5e7f48710088fb5e4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/f5481b22729d465acb1cea3455fc04ce84b0148b",
-                "reference": "f5481b22729d465acb1cea3455fc04ce84b0148b",
+                "url": "https://api.github.com/repos/symfony/process/zipball/2b3ba8722c4aaf3e88011be5e7f48710088fb5e4",
+                "reference": "2b3ba8722c4aaf3e88011be5e7f48710088fb5e4",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3"
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
             "autoload": {
             "description": "Executes commands in sub-processes",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/process/tree/v4.4.22"
+                "source": "https://github.com/symfony/process/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-07T16:22:29+00:00"
+            "time": "2021-12-27T21:01:00+00:00"
         },
         {
             "name": "symfony/routing",
-            "version": "v4.4.22",
+            "version": "v5.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/routing.git",
-                "reference": "049e7c5c41f98511959668791b4adc0898a821b3"
+                "reference": "9eeae93c32ca86746e5d38f3679e9569981038b1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/routing/zipball/049e7c5c41f98511959668791b4adc0898a821b3",
-                "reference": "049e7c5c41f98511959668791b4adc0898a821b3",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/9eeae93c32ca86746e5d38f3679e9569981038b1",
+                "reference": "9eeae93c32ca86746e5d38f3679e9569981038b1",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3"
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
-                "symfony/config": "<4.2",
-                "symfony/dependency-injection": "<3.4",
-                "symfony/yaml": "<3.4"
+                "doctrine/annotations": "<1.12",
+                "symfony/config": "<5.3",
+                "symfony/dependency-injection": "<4.4",
+                "symfony/yaml": "<4.4"
             },
             "require-dev": {
-                "doctrine/annotations": "^1.10.4",
-                "psr/log": "~1.0",
-                "symfony/config": "^4.2|^5.0",
-                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
-                "symfony/expression-language": "^3.4|^4.0|^5.0",
-                "symfony/http-foundation": "^3.4|^4.0|^5.0",
-                "symfony/yaml": "^3.4|^4.0|^5.0"
+                "doctrine/annotations": "^1.12",
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^5.3|^6.0",
+                "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+                "symfony/expression-language": "^4.4|^5.0|^6.0",
+                "symfony/http-foundation": "^4.4|^5.0|^6.0",
+                "symfony/yaml": "^4.4|^5.0|^6.0"
             },
             "suggest": {
-                "doctrine/annotations": "For using the annotation loader",
                 "symfony/config": "For using the all-in-one router or any loader",
                 "symfony/expression-language": "For using expression matching",
                 "symfony/http-foundation": "For using a Symfony Request object",
             ],
             "authors": [
                 {
-                    "name": "Fabien Potencier",
-                    "email": "[email protected]"
+                    "name": "Fabien Potencier",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Maps an HTTP request to a set of configuration variables",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "router",
+                "routing",
+                "uri",
+                "url"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/routing/tree/v5.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-11-23T10:19:22+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v2.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc",
+                "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/container": "^1.1",
+                "symfony/deprecation-contracts": "^2.1"
+            },
+            "conflict": {
+                "ext-psr": "<1.1|>=2"
+            },
+            "suggest": {
+                "symfony/service-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.5-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "[email protected]"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Maps an HTTP request to a set of configuration variables",
+            "description": "Generic abstractions related to writing services",
             "homepage": "https://symfony.com",
             "keywords": [
-                "router",
-                "routing",
-                "uri",
-                "url"
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/routing/tree/v4.4.22"
+                "source": "https://github.com/symfony/service-contracts/tree/v2.5.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-11T12:59:39+00:00"
+            "time": "2021-11-04T16:48:04+00:00"
         },
         {
-            "name": "symfony/service-contracts",
-            "version": "v2.4.0",
+            "name": "symfony/string",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb"
+                "url": "https://github.com/symfony/string.git",
+                "reference": "e6a5d5ecf6589c5247d18e0e74e30b11dfd51a3d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
-                "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
+                "url": "https://api.github.com/repos/symfony/string/zipball/e6a5d5ecf6589c5247d18e0e74e30b11dfd51a3d",
+                "reference": "e6a5d5ecf6589c5247d18e0e74e30b11dfd51a3d",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2.5",
-                "psr/container": "^1.1"
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-intl-grapheme": "~1.0",
+                "symfony/polyfill-intl-normalizer": "~1.0",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php80": "~1.15"
             },
-            "suggest": {
-                "symfony/service-implementation": ""
+            "conflict": {
+                "symfony/translation-contracts": ">=3.0"
             },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-main": "2.4-dev"
-                },
-                "thanks": {
-                    "name": "symfony/contracts",
-                    "url": "https://github.com/symfony/contracts"
-                }
+            "require-dev": {
+                "symfony/error-handler": "^4.4|^5.0|^6.0",
+                "symfony/http-client": "^4.4|^5.0|^6.0",
+                "symfony/translation-contracts": "^1.1|^2",
+                "symfony/var-exporter": "^4.4|^5.0|^6.0"
             },
+            "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Contracts\\Service\\": ""
-                }
+                    "Symfony\\Component\\String\\": ""
+                },
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Generic abstractions related to writing services",
+            "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
             "homepage": "https://symfony.com",
             "keywords": [
-                "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
+                "grapheme",
+                "i18n",
+                "string",
+                "unicode",
+                "utf-8",
+                "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/service-contracts/tree/v2.4.0"
+                "source": "https://github.com/symfony/string/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-01T10:43:52+00:00"
+            "time": "2021-12-16T21:52:00+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v4.4.21",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "eb8f5428cc3b40d6dffe303b195b084f1c5fbd14"
+                "reference": "ff8bb2107b6a549dc3c5dd9c498dcc82c9c098ca"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/eb8f5428cc3b40d6dffe303b195b084f1c5fbd14",
-                "reference": "eb8f5428cc3b40d6dffe303b195b084f1c5fbd14",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/ff8bb2107b6a549dc3c5dd9c498dcc82c9c098ca",
+                "reference": "ff8bb2107b6a549dc3c5dd9c498dcc82c9c098ca",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
                 "symfony/polyfill-mbstring": "~1.0",
-                "symfony/translation-contracts": "^1.1.6|^2"
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/translation-contracts": "^2.3"
             },
             "conflict": {
-                "symfony/config": "<3.4",
-                "symfony/dependency-injection": "<3.4",
-                "symfony/http-kernel": "<4.4",
-                "symfony/yaml": "<3.4"
+                "symfony/config": "<4.4",
+                "symfony/console": "<5.3",
+                "symfony/dependency-injection": "<5.0",
+                "symfony/http-kernel": "<5.0",
+                "symfony/twig-bundle": "<5.0",
+                "symfony/yaml": "<4.4"
             },
             "provide": {
-                "symfony/translation-implementation": "1.0|2.0"
+                "symfony/translation-implementation": "2.3"
             },
             "require-dev": {
-                "psr/log": "~1.0",
-                "symfony/config": "^3.4|^4.0|^5.0",
-                "symfony/console": "^3.4|^4.0|^5.0",
-                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
-                "symfony/finder": "~2.8|~3.0|~4.0|^5.0",
-                "symfony/http-kernel": "^4.4",
-                "symfony/intl": "^3.4|^4.0|^5.0",
-                "symfony/service-contracts": "^1.1.2|^2",
-                "symfony/yaml": "^3.4|^4.0|^5.0"
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^4.4|^5.0|^6.0",
+                "symfony/console": "^5.4|^6.0",
+                "symfony/dependency-injection": "^5.0|^6.0",
+                "symfony/finder": "^4.4|^5.0|^6.0",
+                "symfony/http-client-contracts": "^1.1|^2.0|^3.0",
+                "symfony/http-kernel": "^5.0|^6.0",
+                "symfony/intl": "^4.4|^5.0|^6.0",
+                "symfony/polyfill-intl-icu": "^1.21",
+                "symfony/service-contracts": "^1.1.2|^2|^3",
+                "symfony/yaml": "^4.4|^5.0|^6.0"
             },
             "suggest": {
                 "psr/log-implementation": "To use logging capability in translator",
             },
             "type": "library",
             "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
                 "psr-4": {
                     "Symfony\\Component\\Translation\\": ""
                 },
             "description": "Provides tools to internationalize your application",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/translation/tree/v4.4.21"
+                "source": "https://github.com/symfony/translation/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-03-23T16:25:01+00:00"
+            "time": "2021-12-25T19:45:36+00:00"
         },
         {
             "name": "symfony/translation-contracts",
-            "version": "v2.4.0",
+            "version": "v2.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation-contracts.git",
-                "reference": "95c812666f3e91db75385749fe219c5e494c7f95"
+                "reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/95c812666f3e91db75385749fe219c5e494c7f95",
-                "reference": "95c812666f3e91db75385749fe219c5e494c7f95",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/d28150f0f44ce854e942b671fc2620a98aae1b1e",
+                "reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "2.4-dev"
+                    "dev-main": "2.5-dev"
                 },
                 "thanks": {
                     "name": "symfony/contracts",
                 "standards"
             ],
             "support": {
-                "source": "https://github.com/symfony/translation-contracts/tree/v2.4.0"
+                "source": "https://github.com/symfony/translation-contracts/tree/v2.5.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-03-23T23:28:01+00:00"
+            "time": "2021-08-17T14:20:01+00:00"
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v4.4.22",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "c194bcedde6295f3ec3e9eba1f5d484ea97c41a7"
+                "reference": "1b56c32c3679002b3a42384a580e16e2600f41c1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c194bcedde6295f3ec3e9eba1f5d484ea97c41a7",
-                "reference": "c194bcedde6295f3ec3e9eba1f5d484ea97c41a7",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/1b56c32c3679002b3a42384a580e16e2600f41c1",
+                "reference": "1b56c32c3679002b3a42384a580e16e2600f41c1",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
+                "php": ">=7.2.5",
                 "symfony/polyfill-mbstring": "~1.0",
-                "symfony/polyfill-php72": "~1.5",
-                "symfony/polyfill-php80": "^1.15"
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
-                "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0",
-                "symfony/console": "<3.4"
+                "phpunit/phpunit": "<5.4.3",
+                "symfony/console": "<4.4"
             },
             "require-dev": {
                 "ext-iconv": "*",
-                "symfony/console": "^3.4|^4.0|^5.0",
-                "symfony/process": "^4.4|^5.0",
-                "twig/twig": "^1.43|^2.13|^3.0.4"
+                "symfony/console": "^4.4|^5.0|^6.0",
+                "symfony/process": "^4.4|^5.0|^6.0",
+                "symfony/uid": "^5.1|^6.0",
+                "twig/twig": "^2.13|^3.0.4"
             },
             "suggest": {
                 "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).",
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v4.4.22"
+                "source": "https://github.com/symfony/var-dumper/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-19T13:36:17+00:00"
+            "time": "2021-12-29T10:10:35+00:00"
         },
         {
             "name": "tijsverkoyen/css-to-inline-styles",
-            "version": "2.2.3",
+            "version": "2.2.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
-                "reference": "b43b05cf43c1b6d849478965062b6ef73e223bb5"
+                "reference": "da444caae6aca7a19c0c140f68c6182e337d5b1c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/b43b05cf43c1b6d849478965062b6ef73e223bb5",
-                "reference": "b43b05cf43c1b6d849478965062b6ef73e223bb5",
+                "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/da444caae6aca7a19c0c140f68c6182e337d5b1c",
+                "reference": "da444caae6aca7a19c0c140f68c6182e337d5b1c",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-libxml": "*",
                 "php": "^5.5 || ^7.0 || ^8.0",
-                "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0"
+                "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5"
+                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5 || ^8.5.21 || ^9.5.10"
             },
             "type": "library",
             "extra": {
             "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
             "support": {
                 "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
-                "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.3"
+                "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.4"
             },
-            "time": "2020-07-13T06:12:54+00:00"
+            "time": "2021-12-08T09:12:39+00:00"
         },
         {
             "name": "vlucas/phpdotenv",
-            "version": "v3.6.8",
+            "version": "v5.4.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/vlucas/phpdotenv.git",
-                "reference": "5e679f7616db829358341e2d5cccbd18773bdab8"
+                "reference": "264dce589e7ce37a7ba99cb901eed8249fbec92f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/5e679f7616db829358341e2d5cccbd18773bdab8",
-                "reference": "5e679f7616db829358341e2d5cccbd18773bdab8",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/264dce589e7ce37a7ba99cb901eed8249fbec92f",
+                "reference": "264dce589e7ce37a7ba99cb901eed8249fbec92f",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.4 || ^7.0 || ^8.0",
-                "phpoption/phpoption": "^1.5.2",
-                "symfony/polyfill-ctype": "^1.17"
+                "ext-pcre": "*",
+                "graham-campbell/result-type": "^1.0.2",
+                "php": "^7.1.3 || ^8.0",
+                "phpoption/phpoption": "^1.8",
+                "symfony/polyfill-ctype": "^1.23",
+                "symfony/polyfill-mbstring": "^1.23.1",
+                "symfony/polyfill-php80": "^1.23.1"
             },
             "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.4.1",
                 "ext-filter": "*",
-                "ext-pcre": "*",
-                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20"
+                "phpunit/phpunit": "^7.5.20 || ^8.5.21 || ^9.5.10"
             },
             "suggest": {
-                "ext-filter": "Required to use the boolean validator.",
-                "ext-pcre": "Required to use most of the library."
+                "ext-filter": "Required to use the boolean validator."
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.6-dev"
+                    "dev-master": "5.4-dev"
                 }
             },
             "autoload": {
             "authors": [
                 {
                     "name": "Graham Campbell",
-                    "email": "[email protected]",
-                    "homepage": "https://gjcampbell.co.uk/"
+                    "email": "[email protected]",
+                    "homepage": "https://github.com/GrahamCampbell"
                 },
                 {
                     "name": "Vance Lucas",
                     "email": "[email protected]",
-                    "homepage": "https://vancelucas.com/"
+                    "homepage": "https://github.com/vlucas"
                 }
             ],
             "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
             ],
             "support": {
                 "issues": "https://github.com/vlucas/phpdotenv/issues",
-                "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.8"
+                "source": "https://github.com/vlucas/phpdotenv/tree/v5.4.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-20T14:39:46+00:00"
-        }
-    ],
-    "packages-dev": [
+            "time": "2021-12-12T23:22:04+00:00"
+        },
         {
-            "name": "barryvdh/laravel-debugbar",
-            "version": "v3.5.5",
+            "name": "voku/portable-ascii",
+            "version": "1.6.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/barryvdh/laravel-debugbar.git",
-                "reference": "6420113d90bb746423fa70b9940e9e7c26ebc121"
+                "url": "https://github.com/voku/portable-ascii.git",
+                "reference": "87337c91b9dfacee02452244ee14ab3c43bc485a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/6420113d90bb746423fa70b9940e9e7c26ebc121",
-                "reference": "6420113d90bb746423fa70b9940e9e7c26ebc121",
+                "url": "https://api.github.com/repos/voku/portable-ascii/zipball/87337c91b9dfacee02452244ee14ab3c43bc485a",
+                "reference": "87337c91b9dfacee02452244ee14ab3c43bc485a",
                 "shasum": ""
             },
             "require": {
-                "illuminate/routing": "^6|^7|^8",
-                "illuminate/session": "^6|^7|^8",
-                "illuminate/support": "^6|^7|^8",
-                "maximebf/debugbar": "^1.16.3",
-                "php": ">=7.2",
-                "symfony/debug": "^4.3|^5",
-                "symfony/finder": "^4.3|^5"
+                "php": ">=7.0.0"
             },
             "require-dev": {
-                "mockery/mockery": "^1.3.3",
-                "orchestra/testbench-dusk": "^4|^5|^6",
-                "phpunit/phpunit": "^8.5|^9.0",
-                "squizlabs/php_codesniffer": "^3.5"
+                "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
             },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.5-dev"
-                },
-                "laravel": {
-                    "providers": [
-                        "Barryvdh\\Debugbar\\ServiceProvider"
-                    ],
-                    "aliases": {
-                        "Debugbar": "Barryvdh\\Debugbar\\Facade"
-                    }
-                }
+            "suggest": {
+                "ext-intl": "Use Intl for transliterator_transliterate() support"
             },
+            "type": "library",
             "autoload": {
                 "psr-4": {
-                    "Barryvdh\\Debugbar\\": "src/"
-                },
-                "files": [
-                    "src/helpers.php"
-                ]
+                    "voku\\": "src/voku/"
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
             ],
             "authors": [
                 {
-                    "name": "Barry vd. Heuvel",
-                    "email": "[email protected]"
+                    "name": "Lars Moelleken",
+                    "homepage": "http://www.moelleken.org/"
                 }
             ],
-            "description": "PHP Debugbar integration for Laravel",
+            "description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
+            "homepage": "https://github.com/voku/portable-ascii",
             "keywords": [
-                "debug",
-                "debugbar",
-                "laravel",
-                "profiler",
-                "webprofiler"
+                "ascii",
+                "clean",
+                "php"
             ],
             "support": {
-                "issues": "https://github.com/barryvdh/laravel-debugbar/issues",
-                "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.5.5"
+                "issues": "https://github.com/voku/portable-ascii/issues",
+                "source": "https://github.com/voku/portable-ascii/tree/1.6.1"
             },
             "funding": [
                 {
-                    "url": "https://github.com/barryvdh",
+                    "url": "https://www.paypal.me/moelleken",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/voku",
                     "type": "github"
-                }
-            ],
-            "time": "2021-04-07T11:19:20+00:00"
-        },
-        {
-            "name": "barryvdh/laravel-ide-helper",
-            "version": "v2.8.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/barryvdh/laravel-ide-helper.git",
-                "reference": "5515cabea39b9cf55f98980d0f269dc9d85cfcca"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/5515cabea39b9cf55f98980d0f269dc9d85cfcca",
-                "reference": "5515cabea39b9cf55f98980d0f269dc9d85cfcca",
-                "shasum": ""
-            },
-            "require": {
-                "barryvdh/reflection-docblock": "^2.0.6",
-                "composer/composer": "^1.6 || ^2",
-                "doctrine/dbal": "~2.3",
-                "ext-json": "*",
-                "illuminate/console": "^6 || ^7 || ^8",
-                "illuminate/filesystem": "^6 || ^7 || ^8",
-                "illuminate/support": "^6 || ^7 || ^8",
-                "php": ">=7.2",
-                "phpdocumentor/type-resolver": "^1.1.0"
-            },
-            "require-dev": {
-                "ext-pdo_sqlite": "*",
-                "friendsofphp/php-cs-fixer": "^2",
-                "illuminate/config": "^6 || ^7 || ^8",
-                "illuminate/view": "^6 || ^7 || ^8",
-                "mockery/mockery": "^1.3.3",
-                "orchestra/testbench": "^4 || ^5 || ^6",
-                "phpunit/phpunit": "^8.5 || ^9",
-                "spatie/phpunit-snapshot-assertions": "^1.4 || ^2.2 || ^3 || ^4",
-                "vimeo/psalm": "^3.12"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.8-dev"
                 },
-                "laravel": {
-                    "providers": [
-                        "Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider"
-                    ]
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Barryvdh\\LaravelIdeHelper\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
                 {
-                    "name": "Barry vd. Heuvel",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.",
-            "keywords": [
-                "autocomplete",
-                "codeintel",
-                "helper",
-                "ide",
-                "laravel",
-                "netbeans",
-                "phpdoc",
-                "phpstorm",
-                "sublime"
-            ],
-            "support": {
-                "issues": "https://github.com/barryvdh/laravel-ide-helper/issues",
-                "source": "https://github.com/barryvdh/laravel-ide-helper/tree/v2.8.2"
-            },
-            "funding": [
+                    "url": "https://opencollective.com/portable-ascii",
+                    "type": "open_collective"
+                },
                 {
-                    "url": "https://github.com/barryvdh",
-                    "type": "github"
+                    "url": "https://www.patreon.com/voku",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2020-12-06T08:55:05+00:00"
+            "time": "2022-01-24T18:55:24+00:00"
         },
         {
-            "name": "barryvdh/reflection-docblock",
-            "version": "v2.0.6",
+            "name": "webmozart/assert",
+            "version": "1.10.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/barryvdh/ReflectionDocBlock.git",
-                "reference": "6b69015d83d3daf9004a71a89f26e27d27ef6a16"
+                "url": "https://github.com/webmozarts/assert.git",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/ReflectionDocBlock/zipball/6b69015d83d3daf9004a71a89f26e27d27ef6a16",
-                "reference": "6b69015d83d3daf9004a71a89f26e27d27ef6a16",
+                "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": "^7.2 || ^8.0",
+                "symfony/polyfill-ctype": "^1.8"
             },
-            "require-dev": {
-                "phpunit/phpunit": "~4.0,<4.5"
+            "conflict": {
+                "phpstan/phpstan": "<0.12.20",
+                "vimeo/psalm": "<4.6.1 || 4.6.2"
             },
-            "suggest": {
-                "dflydev/markdown": "~1.0",
-                "erusev/parsedown": "~1.0"
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.13"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0.x-dev"
+                    "dev-master": "1.10-dev"
                 }
             },
             "autoload": {
-                "psr-0": {
-                    "Barryvdh": [
-                        "src/"
-                    ]
+                "psr-4": {
+                    "Webmozart\\Assert\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             ],
             "authors": [
                 {
-                    "name": "Mike van Riel",
-                    "email": "mike.vanriel@naenius.com"
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
                 }
             ],
+            "description": "Assertions to validate method input/output with nice error messages.",
+            "keywords": [
+                "assert",
+                "check",
+                "validate"
+            ],
             "support": {
-                "source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.0.6"
+                "issues": "https://github.com/webmozarts/assert/issues",
+                "source": "https://github.com/webmozarts/assert/tree/1.10.0"
             },
-            "time": "2018-12-13T10:34:14+00:00"
-        },
+            "time": "2021-03-09T10:59:23+00:00"
+        }
+    ],
+    "packages-dev": [
         {
             "name": "composer/ca-bundle",
-            "version": "1.2.9",
+            "version": "1.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/ca-bundle.git",
-                "reference": "78a0e288fdcebf92aa2318a8d3656168da6ac1a5"
+                "reference": "4c679186f2aca4ab6a0f1b0b9cf9252decb44d0b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/78a0e288fdcebf92aa2318a8d3656168da6ac1a5",
-                "reference": "78a0e288fdcebf92aa2318a8d3656168da6ac1a5",
+                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/4c679186f2aca4ab6a0f1b0b9cf9252decb44d0b",
+                "reference": "4c679186f2aca4ab6a0f1b0b9cf9252decb44d0b",
                 "shasum": ""
             },
             "require": {
                 "phpstan/phpstan": "^0.12.55",
                 "psr/log": "^1.0",
                 "symfony/phpunit-bridge": "^4.2 || ^5",
-                "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0"
+                "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0"
             },
             "type": "library",
             "extra": {
             "support": {
                 "irc": "irc://irc.freenode.org/composer",
                 "issues": "https://github.com/composer/ca-bundle/issues",
-                "source": "https://github.com/composer/ca-bundle/tree/1.2.9"
+                "source": "https://github.com/composer/ca-bundle/tree/1.3.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-01-12T12:10:35+00:00"
+            "time": "2021-10-28T20:44:15+00:00"
         },
         {
             "name": "composer/composer",
-            "version": "2.0.13",
+            "version": "2.2.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/composer.git",
-                "reference": "986e8b86b7b570632ad0a905c3726c33dd4c0efb"
+                "reference": "22c41ef275c7bb64fa28fb2c0871a39666832cb9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/composer/zipball/986e8b86b7b570632ad0a905c3726c33dd4c0efb",
-                "reference": "986e8b86b7b570632ad0a905c3726c33dd4c0efb",
+                "url": "https://api.github.com/repos/composer/composer/zipball/22c41ef275c7bb64fa28fb2c0871a39666832cb9",
+                "reference": "22c41ef275c7bb64fa28fb2c0871a39666832cb9",
                 "shasum": ""
             },
             "require": {
                 "composer/ca-bundle": "^1.0",
                 "composer/metadata-minifier": "^1.0",
+                "composer/pcre": "^1.0",
                 "composer/semver": "^3.0",
                 "composer/spdx-licenses": "^1.2",
-                "composer/xdebug-handler": "^1.1",
-                "justinrainbow/json-schema": "^5.2.10",
+                "composer/xdebug-handler": "^2.0",
+                "justinrainbow/json-schema": "^5.2.11",
                 "php": "^5.3.2 || ^7.0 || ^8.0",
-                "psr/log": "^1.0",
+                "psr/log": "^1.0 || ^2.0",
                 "react/promise": "^1.2 || ^2.7",
                 "seld/jsonlint": "^1.4",
                 "seld/phar-utils": "^1.0",
                 "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0",
-                "symfony/filesystem": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0",
-                "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0",
-                "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0"
+                "symfony/filesystem": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
+                "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0",
+                "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0"
             },
             "require-dev": {
                 "phpspec/prophecy": "^1.10",
-                "symfony/phpunit-bridge": "^4.2 || ^5.0"
+                "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0"
             },
             "suggest": {
                 "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0-dev"
+                    "dev-main": "2.2-dev"
                 }
             },
             "autoload": {
                 {
                     "name": "Jordi Boggiano",
                     "email": "[email protected]",
-                    "homepage": "https://seld.be"
+                    "homepage": "https://seld.be"
+                }
+            ],
+            "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.",
+            "homepage": "https://getcomposer.org/",
+            "keywords": [
+                "autoload",
+                "dependency",
+                "package"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/composer/issues",
+                "source": "https://github.com/composer/composer/tree/2.2.5"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-01-21T16:25:52+00:00"
+        },
+        {
+            "name": "composer/metadata-minifier",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/metadata-minifier.git",
+                "reference": "c549d23829536f0d0e984aaabbf02af91f443207"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207",
+                "reference": "c549d23829536f0d0e984aaabbf02af91f443207",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3.2 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "composer/composer": "^2",
+                "phpstan/phpstan": "^0.12.55",
+                "symfony/phpunit-bridge": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\MetadataMinifier\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "[email protected]",
+                    "homepage": "http://seld.be"
                 }
             ],
-            "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.",
-            "homepage": "https://getcomposer.org/",
+            "description": "Small utility library that handles metadata minification and expansion.",
             "keywords": [
-                "autoload",
-                "dependency",
-                "package"
+                "composer",
+                "compression"
             ],
             "support": {
-                "irc": "irc://irc.freenode.org/composer",
-                "issues": "https://github.com/composer/composer/issues",
-                "source": "https://github.com/composer/composer/tree/2.0.13"
+                "issues": "https://github.com/composer/metadata-minifier/issues",
+                "source": "https://github.com/composer/metadata-minifier/tree/1.0.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-27T11:11:08+00:00"
+            "time": "2021-04-07T13:37:33+00:00"
         },
         {
-            "name": "composer/metadata-minifier",
-            "version": "1.0.0",
+            "name": "composer/pcre",
+            "version": "1.0.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/composer/metadata-minifier.git",
-                "reference": "c549d23829536f0d0e984aaabbf02af91f443207"
+                "url": "https://github.com/composer/pcre.git",
+                "reference": "67a32d7d6f9f560b726ab25a061b38ff3a80c560"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207",
-                "reference": "c549d23829536f0d0e984aaabbf02af91f443207",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/67a32d7d6f9f560b726ab25a061b38ff3a80c560",
+                "reference": "67a32d7d6f9f560b726ab25a061b38ff3a80c560",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.3.2 || ^7.0 || ^8.0"
             },
             "require-dev": {
-                "composer/composer": "^2",
-                "phpstan/phpstan": "^0.12.55",
+                "phpstan/phpstan": "^1.3",
+                "phpstan/phpstan-strict-rules": "^1.1",
                 "symfony/phpunit-bridge": "^4.2 || ^5"
             },
             "type": "library",
             },
             "autoload": {
                 "psr-4": {
-                    "Composer\\MetadataMinifier\\": "src"
+                    "Composer\\Pcre\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                     "homepage": "http://seld.be"
                 }
             ],
-            "description": "Small utility library that handles metadata minification and expansion.",
+            "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
             "keywords": [
-                "composer",
-                "compression"
+                "PCRE",
+                "preg",
+                "regex",
+                "regular expression"
             ],
             "support": {
-                "issues": "https://github.com/composer/metadata-minifier/issues",
-                "source": "https://github.com/composer/metadata-minifier/tree/1.0.0"
+                "issues": "https://github.com/composer/pcre/issues",
+                "source": "https://github.com/composer/pcre/tree/1.0.1"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-07T13:37:33+00:00"
+            "time": "2022-01-21T20:24:37+00:00"
         },
         {
             "name": "composer/semver",
-            "version": "3.2.4",
+            "version": "3.2.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/semver.git",
-                "reference": "a02fdf930a3c1c3ed3a49b5f63859c0c20e10464"
+                "reference": "deac27056b57e46faf136fae7b449eeaa71661ee"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/semver/zipball/a02fdf930a3c1c3ed3a49b5f63859c0c20e10464",
-                "reference": "a02fdf930a3c1c3ed3a49b5f63859c0c20e10464",
+                "url": "https://api.github.com/repos/composer/semver/zipball/deac27056b57e46faf136fae7b449eeaa71661ee",
+                "reference": "deac27056b57e46faf136fae7b449eeaa71661ee",
                 "shasum": ""
             },
             "require": {
             "support": {
                 "irc": "irc://irc.freenode.org/composer",
                 "issues": "https://github.com/composer/semver/issues",
-                "source": "https://github.com/composer/semver/tree/3.2.4"
+                "source": "https://github.com/composer/semver/tree/3.2.7"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-11-13T08:59:24+00:00"
+            "time": "2022-01-04T09:57:54+00:00"
         },
         {
             "name": "composer/spdx-licenses",
-            "version": "1.5.5",
+            "version": "1.5.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/spdx-licenses.git",
-                "reference": "de30328a7af8680efdc03e396aad24befd513200"
+                "reference": "a30d487169d799745ca7280bc90fdfa693536901"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/de30328a7af8680efdc03e396aad24befd513200",
-                "reference": "de30328a7af8680efdc03e396aad24befd513200",
+                "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/a30d487169d799745ca7280bc90fdfa693536901",
+                "reference": "a30d487169d799745ca7280bc90fdfa693536901",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.3.2 || ^7.0 || ^8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 7"
+                "phpstan/phpstan": "^0.12.55",
+                "symfony/phpunit-bridge": "^4.2 || ^5"
             },
             "type": "library",
             "extra": {
             "support": {
                 "irc": "irc://irc.freenode.org/composer",
                 "issues": "https://github.com/composer/spdx-licenses/issues",
-                "source": "https://github.com/composer/spdx-licenses/tree/1.5.5"
+                "source": "https://github.com/composer/spdx-licenses/tree/1.5.6"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-12-03T16:04:16+00:00"
+            "time": "2021-11-18T10:14:14+00:00"
         },
         {
             "name": "composer/xdebug-handler",
-            "version": "1.4.6",
+            "version": "2.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/xdebug-handler.git",
-                "reference": "f27e06cd9675801df441b3656569b328e04aa37c"
+                "reference": "0c1a3925ec58a4ec98e992b9c7d171e9e184be0a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/f27e06cd9675801df441b3656569b328e04aa37c",
-                "reference": "f27e06cd9675801df441b3656569b328e04aa37c",
+                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/0c1a3925ec58a4ec98e992b9c7d171e9e184be0a",
+                "reference": "0c1a3925ec58a4ec98e992b9c7d171e9e184be0a",
                 "shasum": ""
             },
             "require": {
+                "composer/pcre": "^1",
                 "php": "^5.3.2 || ^7.0 || ^8.0",
-                "psr/log": "^1.0"
+                "psr/log": "^1 || ^2 || ^3"
             },
             "require-dev": {
-                "phpstan/phpstan": "^0.12.55",
-                "symfony/phpunit-bridge": "^4.2 || ^5"
+                "phpstan/phpstan": "^1.0",
+                "phpstan/phpstan-strict-rules": "^1.1",
+                "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0"
             },
             "type": "library",
             "autoload": {
             "support": {
                 "irc": "irc://irc.freenode.org/composer",
                 "issues": "https://github.com/composer/xdebug-handler/issues",
-                "source": "https://github.com/composer/xdebug-handler/tree/1.4.6"
+                "source": "https://github.com/composer/xdebug-handler/tree/2.0.4"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-03-25T17:01:18+00:00"
+            "time": "2022-01-04T17:06:45+00:00"
         },
         {
             "name": "doctrine/instantiator",
             ],
             "time": "2020-11-10T18:47:58+00:00"
         },
+        {
+            "name": "facade/ignition-contracts",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/facade/ignition-contracts.git",
+                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
+                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.3|^8.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^v2.15.8",
+                "phpunit/phpunit": "^9.3.11",
+                "vimeo/psalm": "^3.17.1"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Facade\\IgnitionContracts\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Freek Van der Herten",
+                    "email": "[email protected]",
+                    "homepage": "https://flareapp.io",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Solution contracts for Ignition",
+            "homepage": "https://github.com/facade/ignition-contracts",
+            "keywords": [
+                "contracts",
+                "flare",
+                "ignition"
+            ],
+            "support": {
+                "issues": "https://github.com/facade/ignition-contracts/issues",
+                "source": "https://github.com/facade/ignition-contracts/tree/1.0.2"
+            },
+            "time": "2020-10-16T08:27:54+00:00"
+        },
         {
             "name": "fakerphp/faker",
-            "version": "v1.14.1",
+            "version": "v1.18.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/FakerPHP/Faker.git",
-                "reference": "ed22aee8d17c7b396f74a58b1e7fefa4f90d5ef1"
+                "reference": "2e77a868f6540695cf5ebf21e5ab472c65f47567"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/ed22aee8d17c7b396f74a58b1e7fefa4f90d5ef1",
-                "reference": "ed22aee8d17c7b396f74a58b1e7fefa4f90d5ef1",
+                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/2e77a868f6540695cf5ebf21e5ab472c65f47567",
+                "reference": "2e77a868f6540695cf5ebf21e5ab472c65f47567",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.1 || ^8.0",
-                "psr/container": "^1.0",
-                "symfony/deprecation-contracts": "^2.2"
+                "psr/container": "^1.0 || ^2.0",
+                "symfony/deprecation-contracts": "^2.2 || ^3.0"
             },
             "conflict": {
                 "fzaninotto/faker": "*"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "v1.15-dev"
+                    "dev-main": "v1.18-dev"
                 }
             },
             "autoload": {
             ],
             "support": {
                 "issues": "https://github.com/FakerPHP/Faker/issues",
-                "source": "https://github.com/FakerPHP/Faker/tree/v.1.14.1"
+                "source": "https://github.com/FakerPHP/Faker/tree/v1.18.0"
             },
-            "time": "2021-03-30T06:27:33+00:00"
+            "time": "2022-01-23T17:56:23+00:00"
         },
         {
             "name": "hamcrest/hamcrest-php",
             "time": "2020-07-09T08:09:16+00:00"
         },
         {
-            "name": "justinrainbow/json-schema",
-            "version": "5.2.10",
+            "name": "itsgoingd/clockwork",
+            "version": "v5.1.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/justinrainbow/json-schema.git",
-                "reference": "2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b"
+                "url": "https://github.com/itsgoingd/clockwork.git",
+                "reference": "e03f8a7f4bcd99ec67e56428e4fc7424de4cefa8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b",
-                "reference": "2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b",
+                "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/e03f8a7f4bcd99ec67e56428e4fc7424de4cefa8",
+                "reference": "e03f8a7f4bcd99ec67e56428e4fc7424de4cefa8",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1",
-                "json-schema/json-schema-test-suite": "1.2.0",
-                "phpunit/phpunit": "^4.8.35"
+                "ext-json": "*",
+                "php": ">=5.6",
+                "psr/log": "1.* || ^2.0"
             },
-            "bin": [
-                "bin/validate-json"
-            ],
             "type": "library",
             "extra": {
-                "branch-alias": {
-                    "dev-master": "5.0.x-dev"
+                "laravel": {
+                    "providers": [
+                        "Clockwork\\Support\\Laravel\\ClockworkServiceProvider"
+                    ],
+                    "aliases": {
+                        "Clockwork": "Clockwork\\Support\\Laravel\\Facade"
+                    }
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "JsonSchema\\": "src/JsonSchema/"
+                    "Clockwork\\": "Clockwork/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             ],
             "authors": [
                 {
-                    "name": "Bruno Prieto Reis",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Justin Rainbow",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Igor Wiedler",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Robert Schönthal",
-                    "email": "[email protected]"
+                    "name": "itsgoingd",
+                    "email": "[email protected]",
+                    "homepage": "https://twitter.com/itsgoingd"
                 }
             ],
-            "description": "A library to validate a json schema.",
-            "homepage": "https://github.com/justinrainbow/json-schema",
+            "description": "php dev tools in your browser",
+            "homepage": "https://underground.works/clockwork",
             "keywords": [
-                "json",
-                "schema"
+                "Devtools",
+                "debugging",
+                "laravel",
+                "logging",
+                "lumen",
+                "profiling",
+                "slim"
             ],
             "support": {
-                "issues": "https://github.com/justinrainbow/json-schema/issues",
-                "source": "https://github.com/justinrainbow/json-schema/tree/5.2.10"
-            },
-            "time": "2020-05-27T16:41:55+00:00"
-        },
-        {
-            "name": "laravel/browser-kit-testing",
-            "version": "v5.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laravel/browser-kit-testing.git",
-                "reference": "fa0efb279c009e2a276f934f8aff946caf66edc7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laravel/browser-kit-testing/zipball/fa0efb279c009e2a276f934f8aff946caf66edc7",
-                "reference": "fa0efb279c009e2a276f934f8aff946caf66edc7",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-json": "*",
-                "illuminate/contracts": "~5.7.0|~5.8.0|^6.0",
-                "illuminate/database": "~5.7.0|~5.8.0|^6.0",
-                "illuminate/http": "~5.7.0|~5.8.0|^6.0",
-                "illuminate/support": "~5.7.0|~5.8.0|^6.0",
-                "mockery/mockery": "^1.0",
-                "php": "^7.1.3|^8.0",
-                "phpunit/phpunit": "^7.5|^8.0|^9.3",
-                "symfony/console": "^4.2",
-                "symfony/css-selector": "^4.2",
-                "symfony/dom-crawler": "^4.2",
-                "symfony/http-foundation": "^4.2",
-                "symfony/http-kernel": "^4.2"
-            },
-            "require-dev": {
-                "laravel/framework": "~5.7.0|~5.8.0|^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "5.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Laravel\\BrowserKitTesting\\": "src/"
-                }
+                "issues": "https://github.com/itsgoingd/clockwork/issues",
+                "source": "https://github.com/itsgoingd/clockwork/tree/v5.1.3"
             },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
+            "funding": [
                 {
-                    "name": "Taylor Otwell",
-                    "email": "[email protected]"
+                    "url": "https://github.com/itsgoingd",
+                    "type": "github"
                 }
             ],
-            "description": "Provides backwards compatibility for BrowserKit testing in the latest Laravel release.",
-            "keywords": [
-                "laravel",
-                "testing"
-            ],
-            "support": {
-                "issues": "https://github.com/laravel/browser-kit-testing/issues",
-                "source": "https://github.com/laravel/browser-kit-testing/tree/v5.2.0"
-            },
-            "time": "2020-10-30T08:49:09+00:00"
+            "time": "2021-12-24T12:24:20+00:00"
         },
         {
-            "name": "maximebf/debugbar",
-            "version": "v1.16.5",
+            "name": "justinrainbow/json-schema",
+            "version": "5.2.11",
             "source": {
                 "type": "git",
-                "url": "https://github.com/maximebf/php-debugbar.git",
-                "reference": "6d51ee9e94cff14412783785e79a4e7ef97b9d62"
+                "url": "https://github.com/justinrainbow/json-schema.git",
+                "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/6d51ee9e94cff14412783785e79a4e7ef97b9d62",
-                "reference": "6d51ee9e94cff14412783785e79a4e7ef97b9d62",
+                "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ab6744b7296ded80f8cc4f9509abbff393399aa",
+                "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1|^8",
-                "psr/log": "^1.0",
-                "symfony/var-dumper": "^2.6|^3|^4|^5"
+                "php": ">=5.3.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.5.20 || ^9.4.2"
-            },
-            "suggest": {
-                "kriswallsmith/assetic": "The best way to manage assets",
-                "monolog/monolog": "Log using Monolog",
-                "predis/predis": "Redis storage"
+                "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1",
+                "json-schema/json-schema-test-suite": "1.2.0",
+                "phpunit/phpunit": "^4.8.35"
             },
+            "bin": [
+                "bin/validate-json"
+            ],
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "5.0.x-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "DebugBar\\": "src/DebugBar/"
+                    "JsonSchema\\": "src/JsonSchema/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             ],
             "authors": [
                 {
-                    "name": "Maxime Bouroumeau-Fuseau",
-                    "email": "[email protected]",
-                    "homepage": "http://maximebf.com"
+                    "name": "Bruno Prieto Reis",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Justin Rainbow",
+                    "email": "[email protected]"
                 },
                 {
-                    "name": "Barry vd. Heuvel",
-                    "email": "[email protected]"
+                    "name": "Igor Wiedler",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Robert Schönthal",
+                    "email": "[email protected]"
                 }
             ],
-            "description": "Debug bar in the browser for php application",
-            "homepage": "https://github.com/maximebf/php-debugbar",
+            "description": "A library to validate a json schema.",
+            "homepage": "https://github.com/justinrainbow/json-schema",
             "keywords": [
-                "debug",
-                "debugbar"
+                "json",
+                "schema"
             ],
             "support": {
-                "issues": "https://github.com/maximebf/php-debugbar/issues",
-                "source": "https://github.com/maximebf/php-debugbar/tree/v1.16.5"
+                "issues": "https://github.com/justinrainbow/json-schema/issues",
+                "source": "https://github.com/justinrainbow/json-schema/tree/5.2.11"
             },
-            "time": "2020-12-07T11:07:24+00:00"
+            "time": "2021-07-22T09:24:00+00:00"
         },
         {
             "name": "mockery/mockery",
-            "version": "1.4.3",
+            "version": "1.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mockery/mockery.git",
-                "reference": "d1339f64479af1bee0e82a0413813fe5345a54ea"
+                "reference": "c10a5f6e06fc2470ab1822fa13fa2a7380f8fbac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mockery/mockery/zipball/d1339f64479af1bee0e82a0413813fe5345a54ea",
-                "reference": "d1339f64479af1bee0e82a0413813fe5345a54ea",
+                "url": "https://api.github.com/repos/mockery/mockery/zipball/c10a5f6e06fc2470ab1822fa13fa2a7380f8fbac",
+                "reference": "c10a5f6e06fc2470ab1822fa13fa2a7380f8fbac",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/mockery/mockery/issues",
-                "source": "https://github.com/mockery/mockery/tree/1.4.3"
+                "source": "https://github.com/mockery/mockery/tree/1.5.0"
             },
-            "time": "2021-02-24T09:51:49+00:00"
+            "time": "2022-01-20T13:18:17+00:00"
         },
         {
             "name": "myclabs/deep-copy",
             "license": [
                 "MIT"
             ],
-            "description": "Create deep copies (clones) of your objects",
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-11-13T09:40:50+00:00"
+        },
+        {
+            "name": "nunomaduro/collision",
+            "version": "v5.11.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nunomaduro/collision.git",
+                "reference": "8b610eef8582ccdc05d8f2ab23305e2d37049461"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/8b610eef8582ccdc05d8f2ab23305e2d37049461",
+                "reference": "8b610eef8582ccdc05d8f2ab23305e2d37049461",
+                "shasum": ""
+            },
+            "require": {
+                "facade/ignition-contracts": "^1.0",
+                "filp/whoops": "^2.14.3",
+                "php": "^7.3 || ^8.0",
+                "symfony/console": "^5.0"
+            },
+            "require-dev": {
+                "brianium/paratest": "^6.1",
+                "fideloper/proxy": "^4.4.1",
+                "fruitcake/laravel-cors": "^2.0.3",
+                "laravel/framework": "8.x-dev",
+                "nunomaduro/larastan": "^0.6.2",
+                "nunomaduro/mock-final-classes": "^1.0",
+                "orchestra/testbench": "^6.0",
+                "phpstan/phpstan": "^0.12.64",
+                "phpunit/phpunit": "^9.5.0"
+            },
+            "type": "library",
+            "extra": {
+                "laravel": {
+                    "providers": [
+                        "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
+                    ]
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "NunoMaduro\\Collision\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nuno Maduro",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "Cli error handling for console/command-line PHP applications.",
             "keywords": [
-                "clone",
-                "copy",
-                "duplicate",
-                "object",
-                "object graph"
+                "artisan",
+                "cli",
+                "command-line",
+                "console",
+                "error",
+                "handling",
+                "laravel",
+                "laravel-zero",
+                "php",
+                "symfony"
             ],
             "support": {
-                "issues": "https://github.com/myclabs/DeepCopy/issues",
-                "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
+                "issues": "https://github.com/nunomaduro/collision/issues",
+                "source": "https://github.com/nunomaduro/collision"
             },
             "funding": [
                 {
-                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
-                    "type": "tidelift"
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/nunomaduro",
+                    "type": "patreon"
                 }
             ],
-            "time": "2020-11-13T09:40:50+00:00"
+            "time": "2022-01-10T16:22:52+00:00"
         },
         {
-            "name": "nikic/php-parser",
-            "version": "v4.10.5",
+            "name": "nunomaduro/larastan",
+            "version": "1.0.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "4432ba399e47c66624bc73c8c0f811e5c109576f"
+                "url": "https://github.com/nunomaduro/larastan.git",
+                "reference": "f5ce15319da184a5e461ef12c60489c15a9cf65b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4432ba399e47c66624bc73c8c0f811e5c109576f",
-                "reference": "4432ba399e47c66624bc73c8c0f811e5c109576f",
+                "url": "https://api.github.com/repos/nunomaduro/larastan/zipball/f5ce15319da184a5e461ef12c60489c15a9cf65b",
+                "reference": "f5ce15319da184a5e461ef12c60489c15a9cf65b",
                 "shasum": ""
             },
             "require": {
-                "ext-tokenizer": "*",
-                "php": ">=7.0"
+                "composer/composer": "^1.0 || ^2.0",
+                "ext-json": "*",
+                "illuminate/console": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "illuminate/container": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "illuminate/contracts": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "illuminate/database": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "illuminate/http": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "illuminate/pipeline": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "illuminate/support": "^6.0 || ^7.0 || ^8.0 || ^9.0",
+                "mockery/mockery": "^0.9 || ^1.0",
+                "php": "^7.2 || ^8.0",
+                "phpstan/phpstan": "^1.0",
+                "symfony/process": "^4.3 || ^5.0 || ^6.0"
             },
             "require-dev": {
-                "ircmaxell/php-yacc": "^0.0.7",
-                "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+                "nikic/php-parser": "^4.13.0",
+                "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0",
+                "phpunit/phpunit": "^7.3 || ^8.2 || ^9.3"
             },
-            "bin": [
-                "bin/php-parse"
-            ],
-            "type": "library",
+            "suggest": {
+                "orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
+            },
+            "type": "phpstan-extension",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "4.9-dev"
+                    "dev-master": "1.0-dev"
+                },
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "PhpParser\\": "lib/PhpParser"
+                    "NunoMaduro\\Larastan\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "BSD-3-Clause"
+                "MIT"
             ],
             "authors": [
                 {
-                    "name": "Nikita Popov"
+                    "name": "Nuno Maduro",
+                    "email": "[email protected]"
                 }
             ],
-            "description": "A PHP parser written in PHP",
+            "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan wrapper for Laravel",
             "keywords": [
-                "parser",
-                "php"
+                "PHPStan",
+                "code analyse",
+                "code analysis",
+                "larastan",
+                "laravel",
+                "package",
+                "php",
+                "static analysis"
             ],
             "support": {
-                "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v4.10.5"
+                "issues": "https://github.com/nunomaduro/larastan/issues",
+                "source": "https://github.com/nunomaduro/larastan/tree/1.0.3"
             },
-            "time": "2021-05-03T19:11:20+00:00"
+            "funding": [
+                {
+                    "url": "https://www.paypal.com/paypalme/enunomaduro",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/canvural",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nunomaduro",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/nunomaduro",
+                    "type": "patreon"
+                }
+            ],
+            "time": "2022-01-20T19:33:48+00:00"
         },
         {
             "name": "phar-io/manifest",
-            "version": "2.0.1",
+            "version": "2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phar-io/manifest.git",
-                "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133"
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/manifest/zipball/85265efd3af7ba3ca4b2a2c34dbfc5788dd29133",
-                "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
                 "shasum": ""
             },
             "require": {
             "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
             "support": {
                 "issues": "https://github.com/phar-io/manifest/issues",
-                "source": "https://github.com/phar-io/manifest/tree/master"
+                "source": "https://github.com/phar-io/manifest/tree/2.0.3"
             },
-            "time": "2020-06-27T14:33:11+00:00"
+            "time": "2021-07-20T11:28:43+00:00"
         },
         {
             "name": "phar-io/version",
         },
         {
             "name": "phpdocumentor/reflection-docblock",
-            "version": "5.2.2",
+            "version": "5.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556"
+                "reference": "622548b623e81ca6d78b721c5e029f4ce664f170"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/069a785b2141f5bcf49f3e353548dc1cce6df556",
-                "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170",
+                "reference": "622548b623e81ca6d78b721c5e029f4ce664f170",
                 "shasum": ""
             },
             "require": {
                 "webmozart/assert": "^1.9.1"
             },
             "require-dev": {
-                "mockery/mockery": "~1.3.2"
+                "mockery/mockery": "~1.3.2",
+                "psalm/phar": "^4.8"
             },
             "type": "library",
             "extra": {
             "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
             "support": {
                 "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
-                "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master"
+                "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0"
             },
-            "time": "2020-09-03T19:13:55+00:00"
+            "time": "2021-10-19T17:43:47+00:00"
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "1.4.0",
+            "version": "1.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0"
+                "reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0",
-                "reference": "6a467b8989322d92aa1c8bf2bebcc6e5c2ba55c0",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/93ebd0014cab80c4ea9f5e297ea48672f1b87706",
+                "reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706",
                 "shasum": ""
             },
             "require": {
                 "phpdocumentor/reflection-common": "^2.0"
             },
             "require-dev": {
-                "ext-tokenizer": "*"
+                "ext-tokenizer": "*",
+                "psalm/phar": "^4.8"
             },
             "type": "library",
             "extra": {
             "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
             "support": {
                 "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
-                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0"
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.0"
             },
-            "time": "2020-09-17T18:55:26+00:00"
+            "time": "2022-01-04T19:58:01+00:00"
         },
         {
             "name": "phpspec/prophecy",
-            "version": "1.13.0",
+            "version": "v1.15.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea"
+                "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/be1996ed8adc35c3fd795488a653f4b518be70ea",
-                "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
+                "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.2",
-                "php": "^7.2 || ~8.0, <8.1",
+                "php": "^7.2 || ~8.0, <8.2",
                 "phpdocumentor/reflection-docblock": "^5.2",
                 "sebastian/comparator": "^3.0 || ^4.0",
                 "sebastian/recursion-context": "^3.0 || ^4.0"
             },
             "require-dev": {
-                "phpspec/phpspec": "^6.0",
+                "phpspec/phpspec": "^6.0 || ^7.0",
                 "phpunit/phpunit": "^8.0 || ^9.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.11.x-dev"
+                    "dev-master": "1.x-dev"
                 }
             },
             "autoload": {
             ],
             "support": {
                 "issues": "https://github.com/phpspec/prophecy/issues",
-                "source": "https://github.com/phpspec/prophecy/tree/1.13.0"
+                "source": "https://github.com/phpspec/prophecy/tree/v1.15.0"
+            },
+            "time": "2021-12-08T12:19:24+00:00"
+        },
+        {
+            "name": "phpstan/phpstan",
+            "version": "1.4.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpstan/phpstan.git",
+                "reference": "1dd8f3e40bf7aa30031a75c65cece99220a161b8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1dd8f3e40bf7aa30031a75c65cece99220a161b8",
+                "reference": "1dd8f3e40bf7aa30031a75c65cece99220a161b8",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1|^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan-shim": "*"
+            },
+            "bin": [
+                "phpstan",
+                "phpstan.phar"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.4-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "PHPStan - PHP Static Analysis Tool",
+            "support": {
+                "issues": "https://github.com/phpstan/phpstan/issues",
+                "source": "https://github.com/phpstan/phpstan/tree/1.4.2"
             },
-            "time": "2021-03-17T13:42:18+00:00"
+            "funding": [
+                {
+                    "url": "https://github.com/ondrejmirtes",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/phpstan",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/phpstan",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-01-18T16:09:11+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "9.2.6",
+            "version": "9.2.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "f6293e1b30a2354e8428e004689671b83871edde"
+                "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f6293e1b30a2354e8428e004689671b83871edde",
-                "reference": "f6293e1b30a2354e8428e004689671b83871edde",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d5850aaf931743067f4bfc1ae4cbd06468400687",
+                "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-libxml": "*",
                 "ext-xmlwriter": "*",
-                "nikic/php-parser": "^4.10.2",
+                "nikic/php-parser": "^4.13.0",
                 "php": ">=7.3",
                 "phpunit/php-file-iterator": "^3.0.3",
                 "phpunit/php-text-template": "^2.0.2",
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
-                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.6"
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.10"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-03-28T07:26:59+00:00"
+            "time": "2021-12-05T09:12:13+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
-            "version": "3.0.5",
+            "version": "3.0.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
-                "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8"
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8",
-                "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
-                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.5"
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2020-09-28T05:57:25+00:00"
+            "time": "2021-12-02T12:48:52+00:00"
         },
         {
             "name": "phpunit/php-invoker",
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.5.4",
+            "version": "9.5.13",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "c73c6737305e779771147af66c96ca6a7ed8a741"
+                "reference": "597cb647654ede35e43b137926dfdfef0fb11743"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c73c6737305e779771147af66c96ca6a7ed8a741",
-                "reference": "c73c6737305e779771147af66c96ca6a7ed8a741",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/597cb647654ede35e43b137926dfdfef0fb11743",
+                "reference": "597cb647654ede35e43b137926dfdfef0fb11743",
                 "shasum": ""
             },
             "require": {
                 "ext-xml": "*",
                 "ext-xmlwriter": "*",
                 "myclabs/deep-copy": "^1.10.1",
-                "phar-io/manifest": "^2.0.1",
+                "phar-io/manifest": "^2.0.3",
                 "phar-io/version": "^3.0.2",
                 "php": ">=7.3",
                 "phpspec/prophecy": "^1.12.1",
-                "phpunit/php-code-coverage": "^9.2.3",
+                "phpunit/php-code-coverage": "^9.2.7",
                 "phpunit/php-file-iterator": "^3.0.5",
                 "phpunit/php-invoker": "^3.1.1",
                 "phpunit/php-text-template": "^2.0.3",
                 "sebastian/global-state": "^5.0.1",
                 "sebastian/object-enumerator": "^4.0.3",
                 "sebastian/resource-operations": "^3.0.3",
-                "sebastian/type": "^2.3",
+                "sebastian/type": "^2.3.4",
                 "sebastian/version": "^3.0.2"
             },
             "require-dev": {
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.4"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.13"
             },
             "funding": [
                 {
-                    "url": "https://phpunit.de/donate.html",
+                    "url": "https://phpunit.de/sponsors.html",
                     "type": "custom"
                 },
                 {
                     "type": "github"
                 }
             ],
-            "time": "2021-03-23T07:16:29+00:00"
+            "time": "2022-01-24T07:33:35+00:00"
         },
         {
             "name": "react/promise",
         },
         {
             "name": "sebastian/exporter",
-            "version": "4.0.3",
+            "version": "4.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65"
+                "reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65",
-                "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/65e8b7db476c5dd267e65eea9cab77584d3cfff9",
+                "reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9",
                 "shasum": ""
             },
             "require": {
                 }
             ],
             "description": "Provides the functionality to export PHP variables for visualization",
-            "homepage": "http://www.github.com/sebastianbergmann/exporter",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
             "keywords": [
                 "export",
                 "exporter"
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/exporter/issues",
-                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.3"
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.4"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2020-09-28T05:24:23+00:00"
+            "time": "2021-11-11T14:18:36+00:00"
         },
         {
             "name": "sebastian/global-state",
-            "version": "5.0.2",
+            "version": "5.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/global-state.git",
-                "reference": "a90ccbddffa067b51f574dea6eb25d5680839455"
+                "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/a90ccbddffa067b51f574dea6eb25d5680839455",
-                "reference": "a90ccbddffa067b51f574dea6eb25d5680839455",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/23bd5951f7ff26f12d4e3242864df3e08dec4e49",
+                "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/global-state/issues",
-                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.2"
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.3"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2020-10-26T15:55:19+00:00"
+            "time": "2021-06-11T13:31:12+00:00"
         },
         {
             "name": "sebastian/lines-of-code",
         },
         {
             "name": "sebastian/type",
-            "version": "2.3.1",
+            "version": "2.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/type.git",
-                "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2"
+                "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/81cd61ab7bbf2de744aba0ea61fae32f721df3d2",
-                "reference": "81cd61ab7bbf2de744aba0ea61fae32f721df3d2",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8cd8a1c753c90bc1a0f5372170e3e489136f914",
+                "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914",
                 "shasum": ""
             },
             "require": {
             "homepage": "https://github.com/sebastianbergmann/type",
             "support": {
                 "issues": "https://github.com/sebastianbergmann/type/issues",
-                "source": "https://github.com/sebastianbergmann/type/tree/2.3.1"
+                "source": "https://github.com/sebastianbergmann/type/tree/2.3.4"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2020-10-26T13:18:59+00:00"
+            "time": "2021-06-15T12:49:02+00:00"
         },
         {
             "name": "sebastian/version",
         },
         {
             "name": "seld/phar-utils",
-            "version": "1.1.1",
+            "version": "1.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/phar-utils.git",
-                "reference": "8674b1d84ffb47cc59a101f5d5a3b61e87d23796"
+                "reference": "9f3452c93ff423469c0d56450431562ca423dcee"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/8674b1d84ffb47cc59a101f5d5a3b61e87d23796",
-                "reference": "8674b1d84ffb47cc59a101f5d5a3b61e87d23796",
+                "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/9f3452c93ff423469c0d56450431562ca423dcee",
+                "reference": "9f3452c93ff423469c0d56450431562ca423dcee",
                 "shasum": ""
             },
             "require": {
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/phar-utils/issues",
-                "source": "https://github.com/Seldaek/phar-utils/tree/master"
-            },
-            "time": "2020-07-07T18:42:57+00:00"
-        },
-        {
-            "name": "squizlabs/php_codesniffer",
-            "version": "3.6.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "ffced0d2c8fa8e6cdc4d695a743271fab6c38625"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ffced0d2c8fa8e6cdc4d695a743271fab6c38625",
-                "reference": "ffced0d2c8fa8e6cdc4d695a743271fab6c38625",
-                "shasum": ""
-            },
-            "require": {
-                "ext-simplexml": "*",
-                "ext-tokenizer": "*",
-                "ext-xmlwriter": "*",
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
-            },
-            "bin": [
-                "bin/phpcs",
-                "bin/phpcbf"
-            ],
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.x-dev"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Greg Sherwood",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
-            "homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
-            "keywords": [
-                "phpcs",
-                "standards"
-            ],
-            "support": {
-                "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
-                "source": "https://github.com/squizlabs/PHP_CodeSniffer",
-                "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
+                "source": "https://github.com/Seldaek/phar-utils/tree/1.2.0"
             },
-            "time": "2021-04-09T00:54:41+00:00"
+            "time": "2021-12-10T11:20:11+00:00"
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v4.4.20",
+            "version": "v5.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "be133557f1b0e6672367325b508e65da5513a311"
+                "reference": "bb3bc3699779fc6d9646270789026a7e2cec7ec7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/be133557f1b0e6672367325b508e65da5513a311",
-                "reference": "be133557f1b0e6672367325b508e65da5513a311",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/bb3bc3699779fc6d9646270789026a7e2cec7ec7",
+                "reference": "bb3bc3699779fc6d9646270789026a7e2cec7ec7",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1.3",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1|^3",
                 "symfony/polyfill-ctype": "~1.8",
-                "symfony/polyfill-mbstring": "~1.0"
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
                 "masterminds/html5": "<2.6"
             },
             "require-dev": {
                 "masterminds/html5": "^2.6",
-                "symfony/css-selector": "^3.4|^4.0|^5.0"
+                "symfony/css-selector": "^4.4|^5.0|^6.0"
             },
             "suggest": {
                 "symfony/css-selector": ""
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v4.4.20"
+                "source": "https://github.com/symfony/dom-crawler/tree/v5.4.2"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-02-14T12:29:41+00:00"
+            "time": "2021-12-28T17:15:56+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v5.2.7",
+            "version": "v5.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "056e92acc21d977c37e6ea8e97374b2a6c8551b0"
+                "reference": "731f917dc31edcffec2c6a777f3698c33bea8f01"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/056e92acc21d977c37e6ea8e97374b2a6c8551b0",
-                "reference": "056e92acc21d977c37e6ea8e97374b2a6c8551b0",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/731f917dc31edcffec2c6a777f3698c33bea8f01",
+                "reference": "731f917dc31edcffec2c6a777f3698c33bea8f01",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.2.5",
-                "symfony/polyfill-ctype": "~1.8"
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-mbstring": "~1.8",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
             "autoload": {
             "description": "Provides basic utilities for the filesystem",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/filesystem/tree/v5.2.7"
+                "source": "https://github.com/symfony/filesystem/tree/v5.4.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-04-01T10:42:13+00:00"
+            "time": "2021-10-28T13:39:27+00:00"
         },
         {
             "name": "theseer/tokenizer",
-            "version": "1.2.0",
+            "version": "1.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/theseer/tokenizer.git",
-                "reference": "75a63c33a8577608444246075ea0af0d052e452a"
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/75a63c33a8577608444246075ea0af0d052e452a",
-                "reference": "75a63c33a8577608444246075ea0af0d052e452a",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
                 "shasum": ""
             },
             "require": {
             "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/master"
+                "source": "https://github.com/theseer/tokenizer/tree/1.2.1"
             },
             "funding": [
                 {
                     "type": "github"
                 }
             ],
-            "time": "2020-07-12T23:59:07+00:00"
-        },
-        {
-            "name": "webmozart/assert",
-            "version": "1.10.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/webmozarts/assert.git",
-                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25",
-                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.2 || ^8.0",
-                "symfony/polyfill-ctype": "^1.8"
-            },
-            "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": "[email protected]"
-                }
-            ],
-            "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.10.0"
-            },
-            "time": "2021-03-09T10:59:23+00:00"
+            "time": "2021-07-28T10:34:58+00:00"
         }
     ],
     "aliases": [],
         "php": "^7.3|^8.0",
         "ext-curl": "*",
         "ext-dom": "*",
+        "ext-fileinfo": "*",
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
     "platform-overrides": {
         "php": "7.3.0"
     },
-    "plugin-api-version": "2.0.0"
+    "plugin-api-version": "2.1.0"
 }
diff --git a/database/factories/Actions/CommentFactory.php b/database/factories/Actions/CommentFactory.php
new file mode 100644 (file)
index 0000000..e81f3fe
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace Database\Factories\Actions;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class CommentFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Actions\Comment::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        $text = $this->faker->paragraph(1);
+        $html = '<p>' . $text . '</p>';
+
+        return [
+            'html'      => $html,
+            'text'      => $text,
+            'parent_id' => null,
+        ];
+    }
+}
diff --git a/database/factories/Actions/TagFactory.php b/database/factories/Actions/TagFactory.php
new file mode 100644 (file)
index 0000000..8d5c77e
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace Database\Factories\Actions;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class TagFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Actions\Tag::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name'  => $this->faker->city,
+            'value' => $this->faker->sentence(3),
+        ];
+    }
+}
diff --git a/database/factories/Actions/WebhookFactory.php b/database/factories/Actions/WebhookFactory.php
new file mode 100644 (file)
index 0000000..662f64f
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace Database\Factories\Actions;
+
+use BookStack\Actions\Webhook;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class WebhookFactory extends Factory
+{
+    protected $model = Webhook::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name'     => 'My webhook for ' . $this->faker->country(),
+            'endpoint' => $this->faker->url,
+            'active'   => true,
+            'timeout'  => 3,
+        ];
+    }
+}
diff --git a/database/factories/Actions/WebhookTrackedEventFactory.php b/database/factories/Actions/WebhookTrackedEventFactory.php
new file mode 100644 (file)
index 0000000..71b8774
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace Database\Factories\Actions;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class WebhookTrackedEventFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'webhook_id' => Webhook::factory(),
+            'event'      => ActivityType::all()[array_rand(ActivityType::all())],
+        ];
+    }
+}
diff --git a/database/factories/Auth/RoleFactory.php b/database/factories/Auth/RoleFactory.php
new file mode 100644 (file)
index 0000000..a952e04
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace Database\Factories\Auth;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class RoleFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Auth\Role::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'display_name' => $this->faker->sentence(3),
+            'description'  => $this->faker->sentence(10),
+        ];
+    }
+}
diff --git a/database/factories/Auth/UserFactory.php b/database/factories/Auth/UserFactory.php
new file mode 100644 (file)
index 0000000..805782f
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+namespace Database\Factories\Auth;
+
+use BookStack\Auth\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class UserFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = User::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        $name = $this->faker->name;
+
+        return [
+            'name'            => $name,
+            'email'           => $this->faker->email,
+            'slug'            => Str::slug($name . '-' . Str::random(5)),
+            'password'        => Str::random(10),
+            'remember_token'  => Str::random(10),
+            'email_confirmed' => 1,
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/BookFactory.php b/database/factories/Entities/Models/BookFactory.php
new file mode 100644 (file)
index 0000000..0613800
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class BookFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Entities\Models\Book::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name'        => $this->faker->sentence,
+            'slug'        => Str::random(10),
+            'description' => $this->faker->paragraph,
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/BookshelfFactory.php b/database/factories/Entities/Models/BookshelfFactory.php
new file mode 100644 (file)
index 0000000..66dd1c1
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class BookshelfFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Entities\Models\Bookshelf::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name'        => $this->faker->sentence,
+            'slug'        => Str::random(10),
+            'description' => $this->faker->paragraph,
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php
new file mode 100644 (file)
index 0000000..4fcd69c
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class ChapterFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Entities\Models\Chapter::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name'        => $this->faker->sentence,
+            'slug'        => Str::random(10),
+            'description' => $this->faker->paragraph,
+        ];
+    }
+}
diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php
new file mode 100644 (file)
index 0000000..c83e0f8
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+
+class PageFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Entities\Models\Page::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        $html = '<p>' . implode('</p>', $this->faker->paragraphs(5)) . '</p>';
+
+        return [
+            'name'           => $this->faker->sentence,
+            'slug'           => Str::random(10),
+            'html'           => $html,
+            'text'           => strip_tags($html),
+            'revision_count' => 1,
+        ];
+    }
+}
diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php
deleted file mode 100644 (file)
index 722f68a..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-<?php
-
-/*
-|--------------------------------------------------------------------------
-| Model Factories
-|--------------------------------------------------------------------------
-|
-| Here you may define all of your model factories. Model factories give
-| you a convenient way to create models for testing and seeding your
-| database. Just tell the factory how a default model should look.
-|
-*/
-
-$factory->define(\BookStack\Auth\User::class, function ($faker) {
-    $name = $faker->name;
-    return [
-        'name' => $name,
-        'email' => $faker->email,
-        'slug' => \Illuminate\Support\Str::slug($name . '-' . \Illuminate\Support\Str::random(5)),
-        'password' => Str::random(10),
-        'remember_token' => Str::random(10),
-        'email_confirmed' => 1
-    ];
-});
-
-$factory->define(\BookStack\Entities\Models\Bookshelf::class, function ($faker) {
-    return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'description' => $faker->paragraph
-    ];
-});
-
-$factory->define(\BookStack\Entities\Models\Book::class, function ($faker) {
-    return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'description' => $faker->paragraph
-    ];
-});
-
-$factory->define(\BookStack\Entities\Models\Chapter::class, function ($faker) {
-    return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'description' => $faker->paragraph
-    ];
-});
-
-$factory->define(\BookStack\Entities\Models\Page::class, function ($faker) {
-    $html = '<p>' . implode('</p>', $faker->paragraphs(5)) . '</p>';
-    return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'html' => $html,
-        'text' => strip_tags($html),
-        'revision_count' => 1
-    ];
-});
-
-$factory->define(\BookStack\Auth\Role::class, function ($faker) {
-    return [
-        'display_name' => $faker->sentence(3),
-        'description' => $faker->sentence(10)
-    ];
-});
-
-$factory->define(\BookStack\Actions\Tag::class, function ($faker) {
-    return [
-        'name' => $faker->city,
-        'value' => $faker->sentence(3)
-    ];
-});
-
-$factory->define(\BookStack\Uploads\Image::class, function ($faker) {
-    return [
-        'name' => $faker->slug . '.jpg',
-        'url' => $faker->url,
-        'path' => $faker->url,
-        'type' => 'gallery',
-        'uploaded_to' => 0
-    ];
-});
-
-$factory->define(\BookStack\Actions\Comment::class, function($faker) {
-    $text = $faker->paragraph(1);
-    $html = '<p>' . $text. '</p>';
-    return [
-        'html' => $html,
-        'text' => $text,
-        'parent_id' => null
-    ];
-});
\ No newline at end of file
diff --git a/database/factories/Uploads/ImageFactory.php b/database/factories/Uploads/ImageFactory.php
new file mode 100644 (file)
index 0000000..c6d0e08
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace Database\Factories\Uploads;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class ImageFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = \BookStack\Uploads\Image::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name'        => $this->faker->slug . '.jpg',
+            'url'         => $this->faker->url,
+            'path'        => $this->faker->url,
+            'type'        => 'gallery',
+            'uploaded_to' => 0,
+        ];
+    }
+}
index 17e71de5f99d933aaafb7d6996cb337203a468da..10ae5222b41065ba8992d02a6f88268f5c374ceb 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateUsersTable extends Migration
 {
@@ -23,11 +23,11 @@ class CreateUsersTable extends Migration
 
         // Create the initial admin user
         DB::table('users')->insert([
-            'name' => 'Admin',
-            'email' => '[email protected]',
-            'password' => bcrypt('password'),
+            'name'       => 'Admin',
+            'email'      => '[email protected]',
+            'password'   => bcrypt('password'),
             'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-            'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
         ]);
     }
 
index 00057f9cffa277455d2b42a54a220e2186294cc3..c647b562d4a4c067b0ed9037cac12cb91675df2b 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreatePasswordResetsTable extends Migration
 {
index 51fb55c48547f86be43d0929b8d44d414c7e40af..966dcd6d9d714c673ea06e4c35d22fe697dc5709 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateBooksTable extends Migration
 {
index 7a1dcec0e88d7e1f616ff010cff1e3d7a36e5636..afba2b3ebd700b04cdd430c997218d8ff235004d 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreatePagesTable extends Migration
 {
@@ -12,8 +12,6 @@ class CreatePagesTable extends Migration
      */
     public function up()
     {
-
-
         Schema::create('pages', function (Blueprint $table) {
             $table->increments('id');
             $table->integer('book_id');
index 61beaa7c39ad227f2dc1f3a9dd7fff8fbd0bb662..f54ab9e2a1b60ff3f5eb813f5f3668400ffaaffc 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateImagesTable extends Migration
 {
index 7974759f26e343f2c75051917ecbfa85324930cd..f557cced4eae5bf258668073632d6b78dec4c97b 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateChaptersTable extends Migration
 {
index 7e402bda34027b199a97b790f9c93f8df72f642d..4dc8784623304f7af21b91da2f8b1d0c992ba780 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddUsersToEntities extends Migration
 {
index 9c06933092122cd5e48051c9ff294de7dc6ce30a..3540678e65f0e120b8acb30c6d5a5829cfdc2002 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreatePageRevisionsTable extends Migration
 {
index f8d9064f248ca85496e2c78019be796c407c36b6..e45e11b53dad01932b3448415b49c31dc145bcd6 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateActivitiesTable extends Migration
 {
index 47a77e29f95a20e59c830b70ba98b260637fda31..c17f72e2fed387a10d2ba5a5dd294a4d325c3109 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 /**
  * Much of this code has been taken from entrust,
@@ -10,7 +10,6 @@ use Illuminate\Database\Migrations\Migration;
  * Full attribution of the database Schema shown below goes to the entrust project.
  *
  * @license MIT
- * @package Zizaco\Entrust
  * @url https://github.com/Zizaco/entrust
  */
 class AddRolesAndPermissions extends Migration
@@ -66,45 +65,43 @@ class AddRolesAndPermissions extends Migration
             $table->primary(['permission_id', 'role_id']);
         });
 
-
         // Create default roles
         $adminId = DB::table('roles')->insertGetId([
-            'name' => 'admin',
+            'name'         => 'admin',
             'display_name' => 'Admin',
-            'description' => 'Administrator of the whole application',
-            'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-            'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            'description'  => 'Administrator of the whole application',
+            'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+            'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
         ]);
         $editorId = DB::table('roles')->insertGetId([
-            'name' => 'editor',
+            'name'         => 'editor',
             'display_name' => 'Editor',
-            'description' => 'User can edit Books, Chapters & Pages',
-            'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-            'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            'description'  => 'User can edit Books, Chapters & Pages',
+            'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+            'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
         ]);
         $viewerId = DB::table('roles')->insertGetId([
-            'name' => 'viewer',
+            'name'         => 'viewer',
             'display_name' => 'Viewer',
-            'description' => 'User can view books & their content behind authentication',
-            'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-            'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            'description'  => 'User can view books & their content behind authentication',
+            'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+            'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
         ]);
 
-
         // Create default CRUD permissions and allocate to admins and editors
         $entities = ['Book', 'Page', 'Chapter', 'Image'];
         $ops = ['Create', 'Update', 'Delete'];
         foreach ($entities as $entity) {
             foreach ($ops as $op) {
                 $newPermId = DB::table('permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower($op),
+                    'name'         => strtolower($entity) . '-' . strtolower($op),
                     'display_name' => $op . ' ' . $entity . 's',
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 DB::table('permission_role')->insert([
                     ['permission_id' => $newPermId, 'role_id' => $adminId],
-                    ['permission_id' => $newPermId, 'role_id' => $editorId]
+                    ['permission_id' => $newPermId, 'role_id' => $editorId],
                 ]);
             }
         }
@@ -115,14 +112,14 @@ class AddRolesAndPermissions extends Migration
         foreach ($entities as $entity) {
             foreach ($ops as $op) {
                 $newPermId = DB::table('permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower($op),
+                    'name'         => strtolower($entity) . '-' . strtolower($op),
                     'display_name' => $op . ' ' . $entity,
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 DB::table('permission_role')->insert([
                     'permission_id' => $newPermId,
-                    'role_id' => $adminId
+                    'role_id'       => $adminId,
                 ]);
             }
         }
@@ -133,10 +130,9 @@ class AddRolesAndPermissions extends Migration
         foreach ($users as $user) {
             DB::table('role_user')->insert([
                 'role_id' => $adminId,
-                'user_id' => $user->id
+                'user_id' => $user->id,
             ]);
         }
-
     }
 
     /**
index 2cef3e6e76da2dea9cae853d3b25bf8e30500443..1b0a8c1c68eafdad43bff0229dd631afd0dbdf13 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateSettingsTable extends Migration
 {
index 497f88adde27ede97761cdb800fa5d6225b1deb6..c28902a4482f347ab01b253c52c2c485c8326e46 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddSearchIndexes extends Migration
 {
@@ -34,22 +34,21 @@ class AddSearchIndexes extends Migration
         $chapters = $sm->listTableDetails('chapters');
 
         if ($pages->hasIndex('search')) {
-            Schema::table('pages', function(Blueprint $table) {
+            Schema::table('pages', function (Blueprint $table) {
                 $table->dropIndex('search');
             });
         }
 
         if ($books->hasIndex('search')) {
-            Schema::table('books', function(Blueprint $table) {
+            Schema::table('books', function (Blueprint $table) {
                 $table->dropIndex('search');
             });
         }
 
         if ($chapters->hasIndex('search')) {
-            Schema::table('chapters', function(Blueprint $table) {
+            Schema::table('chapters', function (Blueprint $table) {
                 $table->dropIndex('search');
             });
         }
-
     }
 }
index 700d7f90f5fcdbaf55c70c9f9a7b4a33c84ba95a..3eec96163d5b59c20600d2524197e2199dec6a27 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateSocialAccountsTable extends Migration
 {
index 105bda49e0865c17642c003db21e8227aa6d4c88..927ef89bce37fa670411dbbcaacb96c7acdcb990 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddEmailConfirmationTable extends Migration
 {
index 90c3508648c2525c21f9cb05bda3ec5bf504b63e..4c267b08ef1b28716dd02e43bdc4fd34e8acb39a 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateViewsTable extends Migration
 {
index 30f978cad5bc8fe93e7f2ffbdfef2aa1bb35bade..09773b1f3b0174eb2d154df4da3226c399a2d5fc 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddEntityIndexes extends Migration
 {
index 2e884b55c6af0d72e419ef1c41c6a6c52bf4839b..a45310b33f3a8f30a7eaf7da1cd71a38399eef18 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class FulltextWeighting extends Migration
 {
@@ -34,19 +34,19 @@ class FulltextWeighting extends Migration
         $chapters = $sm->listTableDetails('chapters');
 
         if ($pages->hasIndex('name_search')) {
-            Schema::table('pages', function(Blueprint $table) {
+            Schema::table('pages', function (Blueprint $table) {
                 $table->dropIndex('name_search');
             });
         }
 
         if ($books->hasIndex('name_search')) {
-            Schema::table('books', function(Blueprint $table) {
+            Schema::table('books', function (Blueprint $table) {
                 $table->dropIndex('name_search');
             });
         }
 
         if ($chapters->hasIndex('name_search')) {
-            Schema::table('chapters', function(Blueprint $table) {
+            Schema::table('chapters', function (Blueprint $table) {
                 $table->dropIndex('name_search');
             });
         }
index 515bc9d8df7e86fcfd5e5640dbe2b6afbd77f5c3..3ebb10bb9796ce681cc0919ffac23d3615ff4afd 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
 use BookStack\Uploads\Image;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddImageUploadTypes extends Migration
 {
@@ -18,7 +18,7 @@ class AddImageUploadTypes extends Migration
             $table->string('type')->index();
         });
 
-        Image::all()->each(function($image) {
+        Image::all()->each(function ($image) {
             $image->path = $image->url;
             $image->type = 'gallery';
             $image->save();
@@ -36,6 +36,5 @@ class AddImageUploadTypes extends Migration
             $table->dropColumn('type');
             $table->dropColumn('path');
         });
-
     }
 }
index 47cb027fad8aa33f54b57fd5976d1037a16ed83a..083f0a5bc72e85ed203e43610938ad8ddb7ecce7 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddUserAvatars extends Migration
 {
index b7663054c2842bf20847846b46a9083f4776883f..002b45aeceedb14ae57caa1aed360bbb5695b847 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddExternalAuthToUsers extends Migration
 {
@@ -28,4 +28,4 @@ class AddExternalAuthToUsers extends Migration
             $table->dropColumn('external_auth_id');
         });
     }
-}
\ No newline at end of file
+}
index 0be6c794017422ff77e964143bb85a78f5de1d47..7139178e8871026d14697bbbe447279a22b54fc8 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddSlugToRevisions extends Migration
 {
index af6bb12320774d85467f2a910f69d786fdae1bc5..1bab5a873b1a0868cb48add0b47acffac969522d 100644 (file)
@@ -1,6 +1,5 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
 
 class UpdatePermissionsAndRoles extends Migration
@@ -21,22 +20,22 @@ class UpdatePermissionsAndRoles extends Migration
 
         // Create & attach new admin permissions
         $permissionsToCreate = [
-            'settings-manage' => 'Manage Settings',
-            'users-manage' => 'Manage Users',
-            'user-roles-manage' => 'Manage Roles & Permissions',
+            'settings-manage'         => 'Manage Settings',
+            'users-manage'            => 'Manage Users',
+            'user-roles-manage'       => 'Manage Roles & Permissions',
             'restrictions-manage-all' => 'Manage All Entity Permissions',
-            'restrictions-manage-own' => 'Manage Entity Permissions On Own Content'
+            'restrictions-manage-own' => 'Manage Entity Permissions On Own Content',
         ];
         foreach ($permissionsToCreate as $name => $displayName) {
             $permissionId = DB::table('permissions')->insertGetId([
-                'name' => $name,
+                'name'         => $name,
                 'display_name' => $displayName,
-                'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
             ]);
             DB::table('permission_role')->insert([
-                'role_id' => $adminRoleId,
-                'permission_id' => $permissionId
+                'role_id'       => $adminRoleId,
+                'permission_id' => $permissionId,
             ]);
         }
 
@@ -46,24 +45,23 @@ class UpdatePermissionsAndRoles extends Migration
         foreach ($entities as $entity) {
             foreach ($ops as $op) {
                 $permissionId = DB::table('permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                    'name'         => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
                     'display_name' => $op . ' ' . $entity . 's',
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 DB::table('permission_role')->insert([
-                    'role_id' => $adminRoleId,
-                    'permission_id' => $permissionId
+                    'role_id'       => $adminRoleId,
+                    'permission_id' => $permissionId,
                 ]);
                 if ($editorRole !== null) {
                     DB::table('permission_role')->insert([
-                        'role_id' => $editorRole->id,
-                        'permission_id' => $permissionId
+                        'role_id'       => $editorRole->id,
+                        'permission_id' => $permissionId,
                     ]);
                 }
             }
         }
-
     }
 
     /**
@@ -85,14 +83,14 @@ class UpdatePermissionsAndRoles extends Migration
         foreach ($entities as $entity) {
             foreach ($ops as $op) {
                 $permissionId = DB::table('permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower($op),
+                    'name'         => strtolower($entity) . '-' . strtolower($op),
                     'display_name' => $op . ' ' . $entity . 's',
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 DB::table('permission_role')->insert([
-                    'role_id' => $adminRoleId,
-                    'permission_id' => $permissionId
+                    'role_id'       => $adminRoleId,
+                    'permission_id' => $permissionId,
                 ]);
             }
         }
@@ -103,14 +101,14 @@ class UpdatePermissionsAndRoles extends Migration
         foreach ($entities as $entity) {
             foreach ($ops as $op) {
                 $permissionId = DB::table('permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower($op),
+                    'name'         => strtolower($entity) . '-' . strtolower($op),
                     'display_name' => $op . ' ' . $entity,
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 DB::table('permission_role')->insert([
-                    'role_id' => $adminRoleId,
-                    'permission_id' => $permissionId
+                    'role_id'       => $adminRoleId,
+                    'permission_id' => $permissionId,
                 ]);
             }
         }
index 5df2353a24e046175b743555c0074762413195d8..67464095260676e42cad648574fba5c10227acad 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddEntityAccessControls extends Migration
 {
@@ -32,7 +32,7 @@ class AddEntityAccessControls extends Migration
             $table->index('restricted');
         });
 
-        Schema::create('restrictions', function(Blueprint $table) {
+        Schema::create('restrictions', function (Blueprint $table) {
             $table->increments('id');
             $table->integer('restrictable_id');
             $table->string('restrictable_type');
@@ -63,7 +63,6 @@ class AddEntityAccessControls extends Migration
             $table->dropColumn('restricted');
         });
 
-
         Schema::table('pages', function (Blueprint $table) {
             $table->dropColumn('restricted');
         });
index e39c77d186a46659e53b155451c3ad7d2a4e6973..d633fb949e9f7949588979cde7b2b11fe63731f4 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddPageRevisionTypes extends Migration
 {
index 794ee6a5fa3bc0749cdcf534a01313cb2cfffe41..2cc296d056b3ae45facd782edb38fb9c8beb94df 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddPageDrafts extends Migration
 {
@@ -12,7 +12,7 @@ class AddPageDrafts extends Migration
      */
     public function up()
     {
-        Schema::table('pages', function(Blueprint $table) {
+        Schema::table('pages', function (Blueprint $table) {
             $table->boolean('draft')->default(false);
             $table->index('draft');
         });
index 2daa32cfbaadcdd1e3338dd7c787ab7d65dd0fe4..27a198dc969bf0127239d2fe84d45643e590066a 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class AddMarkdownSupport extends Migration
 {
index 9bdf4397fd1a4f337d76cd0a8661c22a8e5b63b1..48a913f8201c4b9a706935700daf3cdbabe2578e 100644 (file)
@@ -1,6 +1,5 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
 
 class AddViewPermissionsToRoles extends Migration
@@ -20,16 +19,16 @@ class AddViewPermissionsToRoles extends Migration
         foreach ($entities as $entity) {
             foreach ($ops as $op) {
                 $permId = DB::table('permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                    'name'         => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
                     'display_name' => $op . ' ' . $entity . 's',
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 // Assign view permission to all current roles
                 foreach ($currentRoles as $role) {
                     DB::table('permission_role')->insert([
-                        'role_id' => $role->id,
-                        'permission_id' => $permId
+                        'role_id'       => $role->id,
+                        'permission_id' => $permId,
                     ]);
                 }
             }
index ce11f7b88efb19174f5cfa23783f62afc3a68244..8c3d9124c75e4067b9041288b3eb11ac856a32c4 100644 (file)
@@ -1,7 +1,8 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Str;
 
 class CreateJointPermissionsTable extends Migration
 {
@@ -42,18 +43,18 @@ class CreateJointPermissionsTable extends Migration
 
         // Create the new public role
         $publicRoleData = [
-            'name' => 'public',
+            'name'         => 'public',
             'display_name' => 'Public',
-            'description' => 'The role given to public visitors if allowed',
-            'system_name' => 'public',
-            'hidden' => true,
-            'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-            'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            'description'  => 'The role given to public visitors if allowed',
+            'system_name'  => 'public',
+            'hidden'       => true,
+            'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+            'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
         ];
 
         // Ensure unique name
         while (DB::table('roles')->where('name', '=', $publicRoleData['display_name'])->count() > 0) {
-            $publicRoleData['display_name'] = $publicRoleData['display_name'] . str_random(2);
+            $publicRoleData['display_name'] = $publicRoleData['display_name'] . Str::random(2);
         }
         $publicRoleId = DB::table('roles')->insertGetId($publicRoleData);
 
@@ -67,7 +68,7 @@ class CreateJointPermissionsTable extends Migration
                 // Assign view permission to public
                 DB::table('permission_role')->insert([
                     'permission_id' => $permission->id,
-                    'role_id' => $publicRoleId
+                    'role_id'       => $publicRoleId,
                 ]);
             }
         }
index 55eed60606fef83bd9e89011bba4db2ac73e187e..1c691f56103efdcdb355973d1ab27609a495dd76 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
 
 class CreateTagsTable extends Migration
 {
index c618877ef57cff8f14811807473e217feef424f2..904e8a48e4fd4e88b9a66f26d9101ef082a18d61 100644 (file)
@@ -1,6 +1,5 @@
 <?php
 
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
 
 class AddSummaryToPageRevisions extends Migration
index f666cad2c6efd0546ec151a961869f46ab8dc181..f5f1aa26b98a5d6570fa6b56ca6ff79a285ddf77 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class RemoveHiddenRoles extends Migration
 {
@@ -14,32 +14,32 @@ class RemoveHiddenRoles extends Migration
     public function up()
     {
         // Remove the hidden property from roles
-        Schema::table('roles', function(Blueprint $table) {
+        Schema::table('roles', function (Blueprint $table) {
             $table->dropColumn('hidden');
         });
 
         // Add column to mark system users
-        Schema::table('users', function(Blueprint $table) {
+        Schema::table('users', function (Blueprint $table) {
             $table->string('system_name')->nullable()->index();
         });
 
         // Insert our new public system user.
         $publicUserId = DB::table('users')->insertGetId([
-            'email' => '[email protected]',
-            'name' => 'Guest',
-            'system_name' => 'public',
+            'email'           => '[email protected]',
+            'name'            => 'Guest',
+            'system_name'     => 'public',
             'email_confirmed' => true,
-            'created_at' => \Carbon\Carbon::now(),
-            'updated_at' => \Carbon\Carbon::now(),
+            'created_at'      => \Carbon\Carbon::now(),
+            'updated_at'      => \Carbon\Carbon::now(),
         ]);
-        
+
         // Get the public role
         $publicRole = DB::table('roles')->where('system_name', '=', 'public')->first();
 
         // Connect the new public user to the public role
         DB::table('role_user')->insert([
             'user_id' => $publicUserId,
-            'role_id' => $publicRole->id
+            'role_id' => $publicRole->id,
         ]);
     }
 
@@ -50,14 +50,14 @@ class RemoveHiddenRoles extends Migration
      */
     public function down()
     {
-        Schema::table('roles', function(Blueprint $table) {
+        Schema::table('roles', function (Blueprint $table) {
             $table->boolean('hidden')->default(false);
             $table->index('hidden');
         });
 
         DB::table('users')->where('system_name', '=', 'public')->delete();
 
-        Schema::table('users', function(Blueprint $table) {
+        Schema::table('users', function (Blueprint $table) {
             $table->dropColumn('system_name');
         });
 
index 627c237c4f0dee81e97964965cca7e4603d6d65f..9c5422f08990c32863e3c34c7ecc05bb1ecdf30d 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class CreateAttachmentsTable extends Migration
 {
@@ -38,17 +38,16 @@ class CreateAttachmentsTable extends Migration
         $entity = 'Attachment';
         foreach ($ops as $op) {
             $permissionId = DB::table('role_permissions')->insertGetId([
-                'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                'name'         => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
                 'display_name' => $op . ' ' . $entity . 's',
-                'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
             ]);
             DB::table('permission_role')->insert([
-                'role_id' => $adminRoleId,
-                'permission_id' => $permissionId
+                'role_id'       => $adminRoleId,
+                'permission_id' => $permissionId,
             ]);
         }
-
     }
 
     /**
index 1f7761c2b626a72c353e8fab809f573bc087037e..f77d4a4eba0ccd2da423895f27a29a3dc3c29920 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class CreateCacheTable extends Migration
 {
index 56e76d6df6e94c4755f1584614d6db6459ef6800..b4d154fda42197624b49468abb934d2f39f47f14 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class CreateSessionsTable extends Migration
 {
index a066fb1309445e378f7880457bfd57fceac28498..0f8a3dd34dced1c827a99227f04712ce4823e2f4 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class CreateSearchIndexTable extends Migration
 {
@@ -32,21 +32,21 @@ class CreateSearchIndexTable extends Migration
         $chapters = $sm->listTableDetails('chapters');
 
         if ($pages->hasIndex('search')) {
-            Schema::table('pages', function(Blueprint $table) {
+            Schema::table('pages', function (Blueprint $table) {
                 $table->dropIndex('search');
                 $table->dropIndex('name_search');
             });
         }
 
         if ($books->hasIndex('search')) {
-            Schema::table('books', function(Blueprint $table) {
+            Schema::table('books', function (Blueprint $table) {
                 $table->dropIndex('search');
                 $table->dropIndex('name_search');
             });
         }
 
         if ($chapters->hasIndex('search')) {
-            Schema::table('chapters', function(Blueprint $table) {
+            Schema::table('chapters', function (Blueprint $table) {
                 $table->dropIndex('search');
                 $table->dropIndex('name_search');
             });
@@ -70,7 +70,7 @@ class CreateSearchIndexTable extends Migration
 //        DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)");
 //        DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)");
 //        DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)");
-        
+
         Schema::dropIfExists('search_terms');
     }
 }
index 3583f36f3a9d73fe7c59ac6363c3757c7715edd9..8c6d75e778c9ad02fa0c2c78818d188eb89b1640 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class AddRevisionCounts extends Migration
 {
index 1d69d1fa782ce47b80130cf35670418023755741..7e64d347b4cdb11151a74d1e9ce11a71d0359bd2 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class CreateCommentsTable extends Migration
 {
@@ -35,17 +35,16 @@ class CreateCommentsTable extends Migration
             $entity = 'Comment';
             foreach ($ops as $op) {
                 $permissionId = DB::table('role_permissions')->insertGetId([
-                    'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                    'name'         => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
                     'display_name' => $op . ' ' . $entity . 's',
-                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                    'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
                 ]);
                 DB::table('permission_role')->insert([
-                    'role_id' => $adminRoleId,
-                    'permission_id' => $permissionId
+                    'role_id'       => $adminRoleId,
+                    'permission_id' => $permissionId,
                 ]);
             }
-
         });
     }
 
index 6f99329243a73a8c18cee8b48e667fce15f4b87f..7dd92433801b30cb30fcf88cf3139ee7325e5220 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class AddCoverImageDisplay extends Migration
 {
index 0778e8762dd245e1855c675bf48e4de74bad1621..85e10a74b7e77ec5be3ee7bf439c3b76ead0d6bb 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class AddRoleExternalAuthId extends Migration
 {
index 488c6196830c4549b43b8a3d9fb4517eef7704fc..bb1aec95b6ca95bc831e8429e23a1204c8ea5b0d 100644 (file)
@@ -1,8 +1,9 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 
 class CreateBookshelvesTable extends Migration
 {
@@ -23,7 +24,8 @@ class CreateBookshelvesTable extends Migration
             DB::statement("ALTER TABLE {$prefix}pages ENGINE = InnoDB;");
             DB::statement("ALTER TABLE {$prefix}chapters ENGINE = InnoDB;");
             DB::statement("ALTER TABLE {$prefix}books ENGINE = InnoDB;");
-        } catch (Exception $exception) {}
+        } catch (Exception $exception) {
+        }
 
         // Here we have table drops before the creations due to upgrade issues
         // people were having due to the bookshelves_books table creation failing.
@@ -79,18 +81,18 @@ class CreateBookshelvesTable extends Migration
                 ->where('role_permissions.name', '=', 'book-' . $dbOpName)->get(['roles.id'])->pluck('id');
 
             $permId = DB::table('role_permissions')->insertGetId([
-                'name' => 'bookshelf-' . $dbOpName,
+                'name'         => 'bookshelf-' . $dbOpName,
                 'display_name' => $op . ' ' . 'BookShelves',
-                'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
-                'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                'created_at'   => \Carbon\Carbon::now()->toDateTimeString(),
+                'updated_at'   => \Carbon\Carbon::now()->toDateTimeString(),
             ]);
 
-            $rowsToInsert = $roleIdsWithBookPermission->filter(function($roleId) {
+            $rowsToInsert = $roleIdsWithBookPermission->filter(function ($roleId) {
                 return !is_null($roleId);
-            })->map(function($roleId) use ($permId) {
+            })->map(function ($roleId) use ($permId) {
                 return [
-                    'role_id' => $roleId,
-                    'permission_id' => $permId
+                    'role_id'       => $roleId,
+                    'permission_id' => $permId,
                 ];
             })->toArray();
 
@@ -107,7 +109,7 @@ class CreateBookshelvesTable extends Migration
     public function down()
     {
         // Drop created permissions
-        $ops = ['bookshelf-create-all','bookshelf-create-own','bookshelf-delete-all','bookshelf-delete-own','bookshelf-update-all','bookshelf-update-own','bookshelf-view-all','bookshelf-view-own'];
+        $ops = ['bookshelf-create-all', 'bookshelf-create-own', 'bookshelf-delete-all', 'bookshelf-delete-own', 'bookshelf-update-all', 'bookshelf-update-own', 'bookshelf-view-all', 'bookshelf-view-own'];
 
         $permissionIds = DB::table('role_permissions')->whereIn('name', $ops)
             ->get(['id'])->pluck('id')->toArray();
index 3fcc6822751ea926985fbe9573a5a688ba71b7f0..ae26985ed8d5e111ca9e027c657f7e1f75fdc47a 100644 (file)
@@ -1,9 +1,9 @@
 <?php
 
 use Carbon\Carbon;
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class AddTemplateSupport extends Migration
 {
@@ -22,14 +22,14 @@ class AddTemplateSupport extends Migration
         // Create new templates-manage permission and assign to admin role
         $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
         $permissionId = DB::table('role_permissions')->insertGetId([
-            'name' => 'templates-manage',
+            'name'         => 'templates-manage',
             'display_name' => 'Manage Page Templates',
-            'created_at' => Carbon::now()->toDateTimeString(),
-            'updated_at' => Carbon::now()->toDateTimeString()
+            'created_at'   => Carbon::now()->toDateTimeString(),
+            'updated_at'   => Carbon::now()->toDateTimeString(),
         ]);
         DB::table('permission_role')->insert([
-            'role_id' => $adminRoleId,
-            'permission_id' => $permissionId
+            'role_id'       => $adminRoleId,
+            'permission_id' => $permissionId,
         ]);
     }
 
index 23bd6988c879adad912da4bfb8a5c4781fcc16a8..6321b8187e4cb797ecfa04409c8d3db077e023f6 100644 (file)
@@ -1,8 +1,8 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
 
 class AddUserInvitesTable extends Migration
 {
index eff88247fab066fb9f43b118e2ba8ba752ed1231..4093833794ee30ddc98684e7565ddb5580c57fec 100644 (file)
@@ -16,7 +16,7 @@ class AddApiAuth extends Migration
     {
 
         // Add API tokens table
-        Schema::create('api_tokens', function(Blueprint $table) {
+        Schema::create('api_tokens', function (Blueprint $table) {
             $table->increments('id');
             $table->string('name');
             $table->string('token_id')->unique();
@@ -29,14 +29,14 @@ class AddApiAuth extends Migration
         // Add access-api permission
         $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
         $permissionId = DB::table('role_permissions')->insertGetId([
-            'name' => 'access-api',
+            'name'         => 'access-api',
             'display_name' => 'Access system API',
-            'created_at' => Carbon::now()->toDateTimeString(),
-            'updated_at' => Carbon::now()->toDateTimeString()
+            'created_at'   => Carbon::now()->toDateTimeString(),
+            'updated_at'   => Carbon::now()->toDateTimeString(),
         ]);
         DB::table('permission_role')->insert([
-            'role_id' => $adminRoleId,
-            'permission_id' => $permissionId
+            'role_id'       => $adminRoleId,
+            'permission_id' => $permissionId,
         ]);
     }
 
index f3cafb7323280eb2aeb58d3dfd7f9bd3df1881c1..8f99817d267518f341f316e3bab3f9b7568dba18 100644 (file)
@@ -2,8 +2,8 @@
 
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 
 class RemoveRoleNameField extends Migration
 {
@@ -31,7 +31,7 @@ class RemoveRoleNameField extends Migration
         });
 
         DB::table('roles')->update([
-            "name" => DB::raw("lower(replace(`display_name`, ' ', '-'))"),
+            'name' => DB::raw("lower(replace(`display_name`, ' ', '-'))"),
         ]);
     }
 }
index 7d6a270a919c911213d6e0292c016e8f7ee6bad5..28355265b6b5258ecbe2dbe28cde82aa7365d8dc 100644 (file)
@@ -13,7 +13,7 @@ class AddActivityIndexes extends Migration
      */
     public function up()
     {
-        Schema::table('activities', function(Blueprint $table) {
+        Schema::table('activities', function (Blueprint $table) {
             $table->index('key');
             $table->index('created_at');
         });
@@ -26,7 +26,7 @@ class AddActivityIndexes extends Migration
      */
     public function down()
     {
-        Schema::table('activities', function(Blueprint $table) {
+        Schema::table('activities', function (Blueprint $table) {
             $table->dropIndex('activities_key_index');
             $table->dropIndex('activities_created_at_index');
         });
index d2b63e8d0f9e4ef574d9f60d189594a947b3837b..09ee87f5ade99877b0e04706451eb3bece492e1e 100644 (file)
@@ -13,16 +13,16 @@ class AddEntitySoftDeletes extends Migration
      */
     public function up()
     {
-        Schema::table('bookshelves', function(Blueprint  $table) {
+        Schema::table('bookshelves', function (Blueprint $table) {
             $table->softDeletes();
         });
-        Schema::table('books', function(Blueprint  $table) {
+        Schema::table('books', function (Blueprint $table) {
             $table->softDeletes();
         });
-        Schema::table('chapters', function(Blueprint  $table) {
+        Schema::table('chapters', function (Blueprint $table) {
             $table->softDeletes();
         });
-        Schema::table('pages', function(Blueprint  $table) {
+        Schema::table('pages', function (Blueprint $table) {
             $table->softDeletes();
         });
     }
@@ -34,16 +34,16 @@ class AddEntitySoftDeletes extends Migration
      */
     public function down()
     {
-        Schema::table('bookshelves', function(Blueprint  $table) {
+        Schema::table('bookshelves', function (Blueprint $table) {
             $table->dropSoftDeletes();
         });
-        Schema::table('books', function(Blueprint  $table) {
+        Schema::table('books', function (Blueprint $table) {
             $table->dropSoftDeletes();
         });
-        Schema::table('chapters', function(Blueprint  $table) {
+        Schema::table('chapters', function (Blueprint $table) {
             $table->dropSoftDeletes();
         });
-        Schema::table('pages', function(Blueprint  $table) {
+        Schema::table('pages', function (Blueprint $table) {
             $table->dropSoftDeletes();
         });
     }
index 828dbc656baa5d96095ead0cdf57193d6968ed44..59f13f456ecb02da8ce8495067b9637bf26733d6 100644 (file)
@@ -2,8 +2,8 @@
 
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 
 class SimplifyActivitiesTable extends Migration
 {
@@ -25,7 +25,7 @@ class SimplifyActivitiesTable extends Migration
         DB::table('activities')
             ->where('entity_id', '=', 0)
             ->update([
-                'entity_id' => null,
+                'entity_id'   => null,
                 'entity_type' => null,
             ]);
     }
@@ -40,7 +40,7 @@ class SimplifyActivitiesTable extends Migration
         DB::table('activities')
             ->whereNull('entity_id')
             ->update([
-                'entity_id' => 0,
+                'entity_id'   => 0,
                 'entity_type' => '',
             ]);
 
index bf8bf281feabe0117db20c3b1ea76030af5cb7de..abff3906f55498951daeed8ba47b861380301dab 100644 (file)
@@ -2,8 +2,8 @@
 
 use Illuminate\Database\Migrations\Migration;
 use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 
 class AddOwnedByFieldToEntities extends Migration
 {
diff --git a/database/migrations/2021_05_15_173110_create_favourites_table.php b/database/migrations/2021_05_15_173110_create_favourites_table.php
new file mode 100644 (file)
index 0000000..783bf58
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateFavouritesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('favourites', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('user_id')->index();
+            $table->integer('favouritable_id');
+            $table->string('favouritable_type', 100);
+            $table->timestamps();
+
+            $table->index(['favouritable_id', 'favouritable_type'], 'favouritable_index');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('favourites');
+    }
+}
diff --git a/database/migrations/2021_06_30_173111_create_mfa_values_table.php b/database/migrations/2021_06_30_173111_create_mfa_values_table.php
new file mode 100644 (file)
index 0000000..937fd31
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateMfaValuesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('mfa_values', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('user_id')->index();
+            $table->string('method', 20)->index();
+            $table->text('value');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('mfa_values');
+    }
+}
diff --git a/database/migrations/2021_07_03_085038_add_mfa_enforced_to_roles_table.php b/database/migrations/2021_07_03_085038_add_mfa_enforced_to_roles_table.php
new file mode 100644 (file)
index 0000000..c14d47e
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddMfaEnforcedToRolesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('roles', function (Blueprint $table) {
+            $table->boolean('mfa_enforced');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('roles', function (Blueprint $table) {
+            $table->dropColumn('mfa_enforced');
+        });
+    }
+}
diff --git a/database/migrations/2021_08_28_161743_add_export_role_permission.php b/database/migrations/2021_08_28_161743_add_export_role_permission.php
new file mode 100644 (file)
index 0000000..1da6076
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+use Carbon\Carbon;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+class AddExportRolePermission extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        // Create new templates-manage permission and assign to admin role
+        $roles = DB::table('roles')->get('id');
+        $permissionId = DB::table('role_permissions')->insertGetId([
+            'name'         => 'content-export',
+            'display_name' => 'Export Content',
+            'created_at'   => Carbon::now()->toDateTimeString(),
+            'updated_at'   => Carbon::now()->toDateTimeString(),
+        ]);
+
+        $permissionRoles = $roles->map(function ($role) use ($permissionId) {
+            return [
+                'role_id'       => $role->id,
+                'permission_id' => $permissionId,
+            ];
+        })->values()->toArray();
+
+        DB::table('permission_role')->insert($permissionRoles);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        // Remove content-export permission
+        $contentExportPermission = DB::table('role_permissions')
+            ->where('name', '=', 'content-export')->first();
+
+        DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete();
+        DB::table('role_permissions')->where('id', '=', 'content-export')->delete();
+    }
+}
diff --git a/database/migrations/2021_09_26_044614_add_activities_ip_column.php b/database/migrations/2021_09_26_044614_add_activities_ip_column.php
new file mode 100644 (file)
index 0000000..68391b1
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddActivitiesIpColumn extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->string('ip', 45)->after('user_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->dropColumn('ip');
+        });
+    }
+}
diff --git a/database/migrations/2021_11_26_070438_add_index_for_user_ip.php b/database/migrations/2021_11_26_070438_add_index_for_user_ip.php
new file mode 100644 (file)
index 0000000..eebab79
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddIndexForUserIp extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->index('ip', 'activities_ip_index');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->dropIndex('activities_ip_index');
+        });
+    }
+}
diff --git a/database/migrations/2021_12_07_111343_create_webhooks_table.php b/database/migrations/2021_12_07_111343_create_webhooks_table.php
new file mode 100644 (file)
index 0000000..be4fc53
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateWebhooksTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('webhooks', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name', 150);
+            $table->boolean('active');
+            $table->string('endpoint', 500);
+            $table->timestamps();
+
+            $table->index('name');
+            $table->index('active');
+        });
+
+        Schema::create('webhook_tracked_events', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('webhook_id');
+            $table->string('event', 50);
+            $table->timestamps();
+
+            $table->index('event');
+            $table->index('webhook_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('webhooks');
+        Schema::dropIfExists('webhook_tracked_events');
+    }
+}
diff --git a/database/migrations/2021_12_13_152024_create_jobs_table.php b/database/migrations/2021_12_13_152024_create_jobs_table.php
new file mode 100644 (file)
index 0000000..1be9e8a
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateJobsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('jobs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('queue')->index();
+            $table->longText('payload');
+            $table->unsignedTinyInteger('attempts');
+            $table->unsignedInteger('reserved_at')->nullable();
+            $table->unsignedInteger('available_at');
+            $table->unsignedInteger('created_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('jobs');
+    }
+}
diff --git a/database/migrations/2021_12_13_152120_create_failed_jobs_table.php b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php
new file mode 100644 (file)
index 0000000..6aa6d74
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateFailedJobsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('failed_jobs', function (Blueprint $table) {
+            $table->id();
+            $table->string('uuid')->unique();
+            $table->text('connection');
+            $table->text('queue');
+            $table->longText('payload');
+            $table->longText('exception');
+            $table->timestamp('failed_at')->useCurrent();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('failed_jobs');
+    }
+}
diff --git a/database/migrations/2022_01_03_154041_add_webhooks_timeout_error_columns.php b/database/migrations/2022_01_03_154041_add_webhooks_timeout_error_columns.php
new file mode 100644 (file)
index 0000000..c7258d0
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddWebhooksTimeoutErrorColumns extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('webhooks', function (Blueprint $table) {
+            $table->unsignedInteger('timeout')->default(3);
+            $table->text('last_error')->default('');
+            $table->timestamp('last_called_at')->nullable();
+            $table->timestamp('last_errored_at')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('webhooks', function (Blueprint $table) {
+            $table->dropColumn('timeout');
+            $table->dropColumn('last_error');
+            $table->dropColumn('last_called_at');
+            $table->dropColumn('last_errored_at');
+        });
+    }
+}
similarity index 92%
rename from database/seeds/DatabaseSeeder.php
rename to database/seeders/DatabaseSeeder.php
index d86cb0dddd06947d478dde126f7ae8c685ec17a9..21eaefb19ffde6f1633af29a87340585bcd31c55 100644 (file)
@@ -1,7 +1,9 @@
 <?php
 
-use Illuminate\Database\Seeder;
+namespace Database\Seeders;
+
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Seeder;
 
 class DatabaseSeeder extends Seeder
 {
similarity index 60%
rename from database/seeds/DummyContentSeeder.php
rename to database/seeders/DummyContentSeeder.php
index 611c05246244b426c3c8d711b29909bb8ed6256b..d54732b26357338c7d9b20bcd367254c1a0afe9c 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+namespace Database\Seeders;
+
 use BookStack\Api\ApiToken;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
@@ -10,6 +12,7 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Tools\SearchIndex;
 use Illuminate\Database\Seeder;
+use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
 class DummyContentSeeder extends Seeder
@@ -22,47 +25,47 @@ class DummyContentSeeder extends Seeder
     public function run()
     {
         // Create an editor user
-        $editorUser = factory(User::class)->create();
+        $editorUser = User::factory()->create();
         $editorRole = Role::getRole('editor');
         $editorUser->attachRole($editorRole);
 
         // Create a viewer user
-        $viewerUser = factory(User::class)->create();
+        $viewerUser = User::factory()->create();
         $role = Role::getRole('viewer');
         $viewerUser->attachRole($role);
 
         $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
 
-        factory(\BookStack\Entities\Models\Book::class, 5)->create($byData)
-            ->each(function($book) use ($editorUser, $byData) {
-                $chapters = factory(Chapter::class, 3)->create($byData)
-                    ->each(function($chapter) use ($editorUser, $book, $byData){
-                        $pages = factory(Page::class, 3)->make(array_merge($byData, ['book_id' => $book->id]));
+        \BookStack\Entities\Models\Book::factory()->count(5)->create($byData)
+            ->each(function ($book) use ($byData) {
+                $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);
                     });
-                $pages = factory(Page::class, 3)->make($byData);
+                $pages = Page::factory()->count(3)->make($byData);
                 $book->chapters()->saveMany($chapters);
                 $book->pages()->saveMany($pages);
             });
 
-        $largeBook = factory(\BookStack\Entities\Models\Book::class)->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
-        $pages = factory(Page::class, 200)->make($byData);
-        $chapters = factory(Chapter::class, 50)->make($byData);
+        $largeBook = \BookStack\Entities\Models\Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $pages = Page::factory()->count(200)->make($byData);
+        $chapters = Chapter::factory()->count(50)->make($byData);
         $largeBook->pages()->saveMany($pages);
         $largeBook->chapters()->saveMany($chapters);
 
-        $shelves = factory(Bookshelf::class, 10)->create($byData);
+        $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();
 
diff --git a/database/seeders/LargeContentSeeder.php b/database/seeders/LargeContentSeeder.php
new file mode 100644 (file)
index 0000000..dd91659
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+namespace Database\Seeders;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
+use Illuminate\Database\Seeder;
+use Illuminate\Support\Str;
+
+class LargeContentSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     *
+     * @return void
+     */
+    public function run()
+    {
+        // Create an editor user
+        $editorUser = User::factory()->create();
+        $editorRole = Role::getRole('editor');
+        $editorUser->attachRole($editorRole);
+
+        /** @var Book $largeBook */
+        $largeBook = Book::factory()->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+        $pages = Page::factory()->count(200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+        $chapters = Chapter::factory()->count(50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+
+        $largeBook->pages()->saveMany($pages);
+        $largeBook->chapters()->saveMany($chapters);
+        $all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
+
+        app()->make(PermissionService::class)->buildJointPermissionsForEntity($largeBook);
+        app()->make(SearchIndex::class)->indexEntities($all);
+    }
+}
diff --git a/database/seeds/LargeContentSeeder.php b/database/seeds/LargeContentSeeder.php
deleted file mode 100644 (file)
index 535626b..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Auth\Role;
-use BookStack\Auth\User;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Page;
-use BookStack\Entities\Tools\SearchIndex;
-use Illuminate\Database\Seeder;
-use Illuminate\Support\Str;
-
-class LargeContentSeeder extends Seeder
-{
-    /**
-     * Run the database seeds.
-     *
-     * @return void
-     */
-    public function run()
-    {
-        // Create an editor user
-        $editorUser = factory(User::class)->create();
-        $editorRole = Role::getRole('editor');
-        $editorUser->attachRole($editorRole);
-
-        $largeBook = factory(\BookStack\Entities\Models\Book::class)->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $pages = factory(Page::class, 200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $chapters = factory(Chapter::class, 50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $largeBook->pages()->saveMany($pages);
-        $largeBook->chapters()->saveMany($chapters);
-        app(PermissionService::class)->buildJointPermissions();
-        app(SearchIndex::class)->indexAllEntities();
-    }
-}
diff --git a/dev/api/requests/attachments-create.json b/dev/api/requests/attachments-create.json
new file mode 100644 (file)
index 0000000..8ed34b2
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "name": "My uploaded attachment",
+  "uploaded_to": 8,
+  "link": "https://link.example.com"
+}
\ No newline at end of file
diff --git a/dev/api/requests/attachments-update.json b/dev/api/requests/attachments-update.json
new file mode 100644 (file)
index 0000000..062050b
--- /dev/null
@@ -0,0 +1,5 @@
+{
+  "name": "My updated attachment",
+  "uploaded_to": 4,
+  "link": "https://link.example.com/updated"
+}
\ No newline at end of file
diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http
new file mode 100644 (file)
index 0000000..ee52238
--- /dev/null
@@ -0,0 +1 @@
+GET /api/search?query=cats+{created_by:me}&page=1&count=2
\ No newline at end of file
diff --git a/dev/api/responses/attachments-create.json b/dev/api/responses/attachments-create.json
new file mode 100644 (file)
index 0000000..ee1b21a
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "id": 5,
+  "name": "My uploaded attachment",
+  "extension": "",
+  "uploaded_to": 8,
+  "external": true,
+  "order": 2,
+  "created_by": 1,
+  "updated_by": 1,
+  "created_at": "2021-10-20T06:35:46.000000Z",
+  "updated_at": "2021-10-20T06:35:46.000000Z"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-list.json b/dev/api/responses/attachments-list.json
new file mode 100644 (file)
index 0000000..af652d5
--- /dev/null
@@ -0,0 +1,29 @@
+{
+  "data": [
+    {
+      "id": 3,
+      "name": "datasheet.pdf",
+      "extension": "pdf",
+      "uploaded_to": 8,
+      "external": false,
+      "order": 1,
+      "created_at": "2021-10-11T06:18:49.000000Z",
+      "updated_at": "2021-10-20T06:31:10.000000Z",
+      "created_by": 1,
+      "updated_by": 1
+    },
+    {
+      "id": 4,
+      "name": "Cat reference",
+      "extension": "",
+      "uploaded_to": 9,
+      "external": true,
+      "order": 1,
+      "created_at": "2021-10-20T06:30:11.000000Z",
+      "updated_at": "2021-10-20T06:30:11.000000Z",
+      "created_by": 1,
+      "updated_by": 1
+    }
+  ],
+  "total": 2
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-read.json b/dev/api/responses/attachments-read.json
new file mode 100644 (file)
index 0000000..48d6f72
--- /dev/null
@@ -0,0 +1,25 @@
+{
+  "id": 5,
+  "name": "My link attachment",
+  "extension": "",
+  "uploaded_to": 4,
+  "external": true,
+  "order": 2,
+  "created_by": {
+    "id": 1,
+    "name": "Admin",
+    "slug": "admin"
+  },
+  "updated_by": {
+    "id": 1,
+    "name": "Admin",
+    "slug": "admin"
+  },
+  "created_at": "2021-10-20T06:35:46.000000Z",
+  "updated_at": "2021-10-20T06:37:11.000000Z",
+  "links": {
+    "html": "<a target=\"_blank\" href=\"https://bookstack.local/attachments/5\">My updated attachment</a>",
+    "markdown": "[My updated attachment](https://bookstack.local/attachments/5)"
+  },
+  "content": "https://link.example.com/updated"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-update.json b/dev/api/responses/attachments-update.json
new file mode 100644 (file)
index 0000000..43689eb
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "id": 5,
+  "name": "My updated attachment",
+  "extension": "",
+  "uploaded_to": 4,
+  "external": true,
+  "order": 2,
+  "created_by": 1,
+  "updated_by": 1,
+  "created_at": "2021-10-20T06:35:46.000000Z",
+  "updated_at": "2021-10-20T06:37:11.000000Z"
+}
\ No newline at end of file
index 124305c8cd3527fca0c3117d78005d0fd989e93a..ede6fcc8e5cee0cad9cac82282448c1b35ba37a9 100644 (file)
@@ -5,7 +5,7 @@
   "updated_by": 1,
   "owned_by": 1,
   "slug": "my-new-book",
-  "updated_at": "2020-01-12 14:05:11",
-  "created_at": "2020-01-12 14:05:11",
+  "updated_at": "2020-01-12T14:05:11.000000Z",
+  "created_at": "2020-01-12T14:05:11.000000Z",
   "id": 15
 }
\ No newline at end of file
index 9900b5b0445a3d157237ec95830ccf735672b00e..45a8c542f387b0a8000387fd2ccb2bcb9e72aa65 100644 (file)
@@ -5,8 +5,8 @@
       "name": "BookStack User Guide",
       "slug": "bookstack-user-guide",
       "description": "This is a general guide on using BookStack on a day-to-day basis.",
-      "created_at": "2019-05-05 21:48:46",
-      "updated_at": "2019-12-11 20:57:31",
+      "created_at": "2019-05-05T21:48:46.000000Z",
+      "updated_at": "2019-12-11T20:57:31.000000Z",
       "created_by": 1,
       "updated_by": 1,
       "owned_by": 1,
@@ -17,8 +17,8 @@
       "name": "Inventore inventore quia voluptatem.",
       "slug": "inventore-inventore-quia-voluptatem",
       "description": "Veniam nihil voluptas enim laborum corporis quos sint. Ab rerum voluptas ut iste voluptas magni quibusdam ut. Amet omnis enim voluptate neque facilis.",
-      "created_at": "2019-05-05 22:10:14",
-      "updated_at": "2019-12-11 20:57:23",
+      "created_at": "2019-05-05T22:10:14.000000Z",
+      "updated_at": "2019-12-11T20:57:23.000000Z",
       "created_by": 4,
       "updated_by": 3,
       "owned_by": 3,
index 0b0bce4e8f91571e443063623766ee61c84efb55..7de85addce5525d9e71f924b288c78fff37a3156 100644 (file)
@@ -3,8 +3,8 @@
   "name": "My own book",
   "slug": "my-own-book",
   "description": "This is my own little book",
-  "created_at": "2020-01-12 14:09:59",
-  "updated_at": "2020-01-12 14:11:51",
+  "created_at": "2020-01-12T14:09:59.000000Z",
+  "updated_at": "2020-01-12T14:11:51.000000Z",
   "created_by": {
     "id": 1,
     "name": "Admin"
@@ -29,8 +29,8 @@
     "id": 452,
     "name": "sjovall_m117hUWMu40.jpg",
     "url": "https:\/\/p.rizon.top:443\/http\/bookstack.local\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg",
-    "created_at": "2020-01-12 14:11:51",
-    "updated_at": "2020-01-12 14:11:51",
+    "created_at": "2020-01-12T14:11:51.000000Z",
+    "updated_at": "2020-01-12T14:11:51.000000Z",
     "created_by": 1,
     "updated_by": 1,
     "path": "\/uploads\/images\/cover_book\/2020-01\/sjovall_m117hUWMu40.jpg",
index fd93dc9aef1a3045ed874be2c9451adc7074b910..30c910aa32db870deed44c4954b894f6df8e142e 100644 (file)
@@ -3,8 +3,8 @@
   "name": "My own book",
   "slug": "my-own-book",
   "description": "This is my own little book - updated",
-  "created_at": "2020-01-12 14:09:59",
-  "updated_at": "2020-01-12 14:16:10",
+  "created_at": "2020-01-12T14:09:59.000000Z",
+  "updated_at": "2020-01-12T14:16:10.000000Z",
   "created_by": 1,
   "updated_by": 1,
   "owned_by": 1,
index a990f278bac30e02c907e824c3a66841902b5bb2..81b422c255bb476fa4dcea31c2a9e71fba46a3a6 100644 (file)
@@ -7,16 +7,16 @@
   "updated_by": 1,
   "owned_by": 1,
   "slug": "my-fantastic-new-chapter",
-  "updated_at": "2020-05-22 22:59:55",
-  "created_at": "2020-05-22 22:59:55",
+  "updated_at": "2020-05-22T22:59:55.000000Z",
+  "created_at": "2020-05-22T22:59:55.000000Z",
   "id": 74,
   "book": {
     "id": 1,
     "name": "BookStack User Guide",
     "slug": "bookstack-user-guide",
     "description": "This is a general guide on using BookStack on a day-to-day basis.",
-    "created_at": "2019-05-05 21:48:46",
-    "updated_at": "2019-12-11 20:57:31",
+    "created_at": "2019-05-05T21:48:46.000000Z",
+    "updated_at": "2019-12-11T20:57:31.000000Z",
     "created_by": 1,
     "updated_by": 1
   },
       "name": "Category",
       "value": "Top Content",
       "order": 0,
-      "created_at": "2020-05-22 22:59:55",
-      "updated_at": "2020-05-22 22:59:55"
+      "created_at": "2020-05-22T22:59:55.000000Z",
+      "updated_at": "2020-05-22T22:59:55.000000Z"
     },
     {
       "name": "Rating",
       "value": "Highest",
       "order": 0,
-      "created_at": "2020-05-22 22:59:55",
-      "updated_at": "2020-05-22 22:59:55"
+      "created_at": "2020-05-22T22:59:55.000000Z",
+      "updated_at": "2020-05-22T22:59:55.000000Z"
     }
   ]
 }
\ No newline at end of file
index 72ed7534df2eba47e16fa883dde9d216cb37c809..9790286b0a2ccf52bf51743552878cd63b618121 100644 (file)
@@ -7,8 +7,8 @@
       "slug": "content-creation",
       "description": "How to create documentation on whatever subject you need to write about.",
       "priority": 3,
-      "created_at": "2019-05-05 21:49:56",
-      "updated_at": "2019-09-28 11:24:23",
+      "created_at": "2019-05-05:",
+      "updated_at": "2019-09-28T11:24:23.000000Z",
       "created_by": 1,
       "updated_by": 1,
       "owned_by": 1
@@ -20,8 +20,8 @@
       "slug": "managing-content",
       "description": "How to keep things organised and orderly in the system for easier navigation and better user experience.",
       "priority": 5,
-      "created_at": "2019-05-05 21:58:07",
-      "updated_at": "2019-10-17 15:05:34",
+      "created_at": "2019-05-05T21:58:07.000000Z",
+      "updated_at": "2019-10-17T15:05:34.000000Z",
       "created_by": 3,
       "updated_by": 3,
       "owned_by": 3
index 41fed80efc1a3786ca4919c9d8d33a7937e2197a..a51b406c77fd1c5d73783cbd2c73201447b1f288 100644 (file)
@@ -5,8 +5,8 @@
   "name": "Content Creation",
   "description": "How to create documentation on whatever subject you need to write about.",
   "priority": 3,
-  "created_at": "2019-05-05 21:49:56",
-  "updated_at": "2019-09-28 11:24:23",
+  "created_at": "2019-05-05T21:49:56.000000Z",
+  "updated_at": "2019-09-28T11:24:23.000000Z",
   "created_by": {
     "id": 1,
     "name": "Admin"
@@ -34,8 +34,8 @@
       "name": "How to create page content",
       "slug": "how-to-create-page-content",
       "priority": 0,
-      "created_at": "2019-05-05 21:49:58",
-      "updated_at": "2019-08-26 14:32:59",
+      "created_at": "2019-05-05T21:49:58.000000Z",
+      "updated_at": "2019-08-26T14:32:59.000000Z",
       "created_by": 1,
       "updated_by": 1,
       "draft": false,
@@ -49,8 +49,8 @@
       "name": "Good book structure",
       "slug": "good-book-structure",
       "priority": 1,
-      "created_at": "2019-05-05 22:01:55",
-      "updated_at": "2019-06-06 12:03:04",
+      "created_at": "2019-05-05T22:01:55.000000Z",
+      "updated_at": "2019-06-06T12:03:04.000000Z",
       "created_by": 3,
       "updated_by": 3,
       "draft": false,
index 11dedd0ca897f6efd4ebbc207e3ed988ba418ca3..9ce88dab2266676b9a07d31c642ba569128021dd 100644 (file)
@@ -5,8 +5,8 @@
   "name": "My fantastic updated chapter",
   "description": "This is an updated chapter that I've altered via the API",
   "priority": 7,
-  "created_at": "2020-05-22 23:03:35",
-  "updated_at": "2020-05-22 23:07:20",
+  "created_at": "2020-05-22T23:03:35.000000Z",
+  "updated_at": "2020-05-22T23:07:20.000000Z",
   "created_by": 1,
   "updated_by": 1,
   "owned_by": 1,
@@ -15,8 +15,8 @@
     "name": "BookStack User Guide",
     "slug": "bookstack-user-guide",
     "description": "This is a general guide on using BookStack on a day-to-day basis.",
-    "created_at": "2019-05-05 21:48:46",
-    "updated_at": "2019-12-11 20:57:31",
+    "created_at": "2019-05-05T21:48:46.000000Z",
+    "updated_at": "2019-12-11T20:57:31.000000Z",
     "created_by": 1,
     "updated_by": 1
   },
       "name": "Category",
       "value": "Kinda Good Content",
       "order": 0,
-      "created_at": "2020-05-22 23:07:20",
-      "updated_at": "2020-05-22 23:07:20"
+      "created_at": "2020-05-22T23:07:20.000000Z",
+      "updated_at": "2020-05-22T23:07:20.000000Z"
     },
     {
       "name": "Rating",
       "value": "Medium",
       "order": 0,
-      "created_at": "2020-05-22 23:07:20",
-      "updated_at": "2020-05-22 23:07:20"
+      "created_at": "2020-05-22T23:07:20.000000Z",
+      "updated_at": "2020-05-22T23:07:20.000000Z"
     }
   ]
 }
\ No newline at end of file
index 0b19fb47309d11af43a5bc8ddbb9eabeb2c49713..eeaa5303af6d5eea824c59c733945b209d46add0 100644 (file)
@@ -6,8 +6,8 @@
        "slug": "my-api-page",
        "html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
        "priority": 14,
-       "created_at": "2020-11-28 15:01:39",
-       "updated_at": "2020-11-28 15:01:39",
+       "created_at": "2020-11-28T15:01:39.000000Z",
+       "updated_at": "2020-11-28T15:01:39.000000Z",
        "created_by": {
                "id": 1,
                "name": "Admin"
index 9c162c6b6ee1200a9477cbed1d4ce6f977477efc..2ff4aeb3a82f16eb8dc97008479e40d23a0215dd 100644 (file)
@@ -9,8 +9,8 @@
                        "priority": 0,
                        "draft": false,
                        "template": false,
-                       "created_at": "2019-05-05 21:49:58",
-                       "updated_at": "2020-07-04 15:50:58",
+                       "created_at": "2019-05-05T21:49:58.000000Z",
+                       "updated_at": "2020-07-04T15:50:58.000000Z",
                        "created_by": 1,
                        "updated_by": 1,
                        "owned_by": 1
@@ -24,8 +24,8 @@
                        "priority": 2,
                        "draft": false,
                        "template": false,
-                       "created_at": "2019-05-05 21:53:30",
-                       "updated_at": "2019-06-06 12:03:04",
+                       "created_at": "2019-05-05T21:53:30.000000Z",
+                       "updated_at": "2019-06-06T12:03:04.000000Z",
                        "created_by": 1,
                        "updated_by": 1,
                        "owned_by": 1
@@ -39,8 +39,8 @@
                        "priority": 3,
                        "draft": false,
                        "template": false,
-                       "created_at": "2019-05-05 21:53:49",
-                       "updated_at": "2019-12-18 21:56:52",
+                       "created_at": "2019-05-05T21:53:49.000000Z",
+                       "updated_at": "2019-12-18T21:56:52.000000Z",
                        "created_by": 1,
                        "updated_by": 1,
                        "owned_by": 1
index 93f7770ac7850650496087ff191c570c7bd3787e..9a21cd44cad93a2fc13846d7c54e687faece45da 100644 (file)
@@ -6,8 +6,8 @@
        "slug": "a-page-written-in-markdown",
        "html": "<h1 id=\"bkmrk-how-this-is-built\">How this is built</h1>\r\n<p id=\"bkmrk-this-page-is-written\">This page is written in markdown. BookStack stores the page data in HTML.</p>\r\n<p id=\"bkmrk-here%27s-a-cute-pictur\">Here's a cute picture of my cat:</p>\r\n<p id=\"bkmrk-\"><a href=\"http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg\"><img src=\"http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg\" alt=\"yXSrubes.jpg\"></a></p>",
        "priority": 13,
-       "created_at": "2020-02-02 21:40:38",
-       "updated_at": "2020-11-28 14:43:20",
+       "created_at": "2020-02-02T21:40:38.000000Z",
+       "updated_at": "2020-11-28T14:43:20.000000Z",
        "created_by": {
                "id": 1,
                "name": "Admin"
index ae5c0ea3fcb7a9a22708d03979545b33bf9bacb5..0b8b2374c180fe66c4497b2f56095b5fc5cb4675 100644 (file)
@@ -6,8 +6,8 @@
        "slug": "my-updated-api-page",
        "html": "<p id=\"bkmrk-my-new-api-page---up\">my new API page - Updated</p>",
        "priority": 16,
-       "created_at": "2020-11-28 15:10:54",
-       "updated_at": "2020-11-28 15:13:03",
+       "created_at": "2020-11-28T15:10:54.000000Z",
+       "updated_at": "2020-11-28T15:13:03.000000Z",
        "created_by": {
                "id": 1,
                "name": "Admin"
diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json
new file mode 100644 (file)
index 0000000..2c7584e
--- /dev/null
@@ -0,0 +1,67 @@
+{
+  "data": [
+    {
+      "id": 84,
+      "book_id": 1,
+      "slug": "a-chapter-for-cats",
+      "name": "A chapter for cats",
+      "created_at": "2021-11-14T15:57:35.000000Z",
+      "updated_at": "2021-11-14T15:57:35.000000Z",
+      "type": "chapter",
+      "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats",
+      "preview_html": {
+        "name": "A chapter for <strong>cats</strong>",
+        "content": "...once a bunch of <strong>cats</strong> named tony...behaviour of <strong>cats</strong> is unsuitable"
+      },
+      "tags": []
+    },
+    {
+      "name": "The hows and whys of cats",
+      "id": 396,
+      "slug": "the-hows-and-whys-of-cats",
+      "book_id": 1,
+      "chapter_id": 75,
+      "draft": false,
+      "template": false,
+      "created_at": "2021-05-15T16:28:10.000000Z",
+      "updated_at": "2021-11-14T15:56:49.000000Z",
+      "type": "page",
+      "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats",
+      "preview_html": {
+        "name": "The hows and whys of <strong>cats</strong>",
+        "content": "...people ask why <strong>cats</strong>? but there are...the reason that <strong>cats</strong> are fast are due to..."
+      },
+      "tags": [
+        {
+          "name": "Animal",
+          "value": "Cat",
+          "order": 0
+        },
+        {
+          "name": "Category",
+          "value": "Top Content",
+          "order": 0
+        }
+      ]
+    },
+    {
+      "name": "How advanced are cats?",
+      "id": 362,
+      "slug": "how-advanced-are-cats",
+      "book_id": 13,
+      "chapter_id": 73,
+      "draft": false,
+      "template": false,
+      "created_at": "2020-11-29T21:55:07.000000Z",
+      "updated_at": "2021-11-14T16:02:39.000000Z",
+      "type": "page",
+      "url": "https://example.com/books/my-book/page/how-advanced-are-cats",
+      "preview_html": {
+        "name": "How advanced are <strong>cats</strong>?",
+        "content": "<strong>cats</strong> are some of the most advanced animals in the world."
+      },
+      "tags": []
+    }
+  ],
+  "total": 3
+}
\ No newline at end of file
index fafa4c9cd1e64f82bf4e81db8fbcf7aca256b620..9988c782c1a7db50aec3ca789d17ec5cfcd66cc5 100644 (file)
@@ -5,7 +5,7 @@
   "updated_by": 1,
   "owned_by": 1,
   "slug": "my-shelf",
-  "updated_at": "2020-04-10 13:24:09",
-  "created_at": "2020-04-10 13:24:09",
+  "updated_at": "2020-04-10T13:24:09.000000Z",
+  "created_at": "2020-04-10T13:24:09.000000Z",
   "id": 14
 }
\ No newline at end of file
index f5e9d03bb6f10ba6df1ed1677782ca788c7bc0a6..82ead7d93a3da98279cef96cb1d2a905218fe317 100644 (file)
@@ -5,8 +5,8 @@
       "name": "Qui qui aspernatur autem molestiae libero necessitatibus molestias.",
       "slug": "qui-qui-aspernatur-autem-molestiae-libero-necessitatibus-molestias",
       "description": "Enim dolor ut quia error dolores est. Aut distinctio consequuntur non nisi nostrum. Labore cupiditate error labore aliquid provident impedit voluptatibus. Quaerat impedit excepturi eius qui eius voluptatem reiciendis.",
-      "created_at": "2019-05-05 22:10:16",
-      "updated_at": "2020-04-10 13:00:45",
+      "created_at": "2019-05-05T22:10:16.000000Z",
+      "updated_at": "2020-04-10T13:00:45.000000Z",
       "created_by": 4,
       "updated_by": 1,
       "owned_by": 1,
@@ -17,8 +17,8 @@
       "name": "Ipsum aut inventore fuga libero non facilis.",
       "slug": "ipsum-aut-inventore-fuga-libero-non-facilis",
       "description": "Labore culpa modi perspiciatis harum sit. Maxime non et nam est. Quae ut laboriosam repellendus sunt quisquam. Velit at est perspiciatis nesciunt adipisci nobis illo. Sed possimus odit optio officiis nisi voluptates officiis dolor.",
-      "created_at": "2019-05-05 22:10:16",
-      "updated_at": "2020-04-10 13:00:58",
+      "created_at": "2019-05-05T22:10:16.000000Z",
+      "updated_at": "2020-04-10T13:00:58.000000Z",
       "created_by": 4,
       "updated_by": 1,
       "owned_by": 1,
@@ -29,8 +29,8 @@
       "name": "Omnis reiciendis aut molestias sint accusantium.",
       "slug": "omnis-reiciendis-aut-molestias-sint-accusantium",
       "description": "Qui ea occaecati alias est dolores voluptatem doloribus. Ad reiciendis corporis vero nostrum omnis et. Non doloribus ut eaque ut quos dolores.",
-      "created_at": "2019-05-05 22:10:16",
-      "updated_at": "2020-04-10 13:00:53",
+      "created_at": "2019-05-05T22:10:16.000000Z",
+      "updated_at": "2020-04-10T13:00:53.000000Z",
       "created_by": 4,
       "updated_by": 1,
       "owned_by": 4,
index d663e82c5fe2bd074c9ee1e7c24f87869094d35f..f2afcdac0268a39d51a21da8fe1356ccd554e87f 100644 (file)
@@ -15,8 +15,8 @@
     "id": 1,
     "name": "Admin"
   },
-  "created_at": "2020-04-10 13:24:09",
-  "updated_at": "2020-04-10 13:31:04",
+  "created_at": "2020-04-10T13:24:09.000000Z",
+  "updated_at": "2020-04-10T13:31:04.000000Z",
   "tags": [
     {
       "id": 16,
@@ -29,8 +29,8 @@
     "id": 501,
     "name": "anafrancisconi_Sp04AfFCPNM.jpg",
     "url": "http://bookstack.local/uploads/images/cover_book/2020-04/anafrancisconi_Sp04AfFCPNM.jpg",
-    "created_at": "2020-04-10 13:31:04",
-    "updated_at": "2020-04-10 13:31:04",
+    "created_at": "2020-04-10T13:31:04.000000Z",
+    "updated_at": "2020-04-10T13:31:04.000000Z",
     "created_by": 1,
     "updated_by": 1,
     "path": "/uploads/images/cover_book/2020-04/anafrancisconi_Sp04AfFCPNM.jpg",
index 4bde44b54d5f2e2a188788d306fae0b0cdede022..551dacb1f305aebcf55b2d3355266676cc8151a9 100644 (file)
@@ -7,6 +7,6 @@
   "updated_by": 1,
   "owned_by": 1,
   "image_id": 501,
-  "created_at": "2020-04-10 13:24:09",
-  "updated_at": "2020-04-10 13:48:22"
+  "created_at": "2020-04-10T13:24:09.000000Z",
+  "updated_at": "2020-04-10T13:48:22.000000Z"
 }
\ No newline at end of file
index 895ad595ae7b73cf4077a76bf717092c5146a2ff..b1bf93349e5af64c238dc197be2cf65a96d4b7f9 100644 (file)
@@ -5,9 +5,11 @@ WORKDIR /app
 
 # Install additional dependacnies and configure apache
 RUN apt-get update -y \
-    && apt-get install -y git zip unzip libpng-dev libldap2-dev wait-for-it \
+    && apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
     && docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
-    && docker-php-ext-install pdo_mysql gd ldap \
+    && docker-php-ext-install pdo_mysql gd ldap zip \
+    && pecl install xdebug \
+    && docker-php-ext-enable xdebug \
     && a2enmod rewrite \
     && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
     && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
@@ -20,4 +22,4 @@ RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
 
 # Use the default production configuration and update it as required
 RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
-    && sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
\ No newline at end of file
+    && sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
diff --git a/dev/docker/php/conf.d/xdebug.ini b/dev/docker/php/conf.d/xdebug.ini
new file mode 100644 (file)
index 0000000..8eaad61
--- /dev/null
@@ -0,0 +1,7 @@
+zend_extension=xdebug
+
+[xdebug]
+xdebug.mode=debug
+xdebug.client_host=host.docker.internal
+xdebug.start_with_request=yes
+xdebug.client_port=9090
\ No newline at end of file
index fc8e6646fc688deef58a76f60d20e22d5659958f..139055b3db26953670b03d4b31003232b6963286 100644 (file)
@@ -6,6 +6,8 @@ WARNING: This system is currently in alpha so may incur changes. Once we've gath
 
 ## Getting Started
 
+*[Video Guide](https://www.youtube.com/watch?v=YVbpm_35crQ)*
+
 This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
 You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
 
@@ -50,6 +52,23 @@ This method allows you to register a custom social authentication driver within
 
 *See "Custom Socialite Service Example" below.*
 
+### `Theme::registerCommand`
+
+This method allows you to register a custom command which can then be used via the artisan console.
+
+**Arguments**
+- string $driverName
+- array $config
+- string $socialiteHandler
+
+**Example**
+
+*See "Custom Command Registration Example" below for a more detailed example.*
+
+```php
+Theme::registerCommand(new SayHelloCommand());
+```
+
 ## Available Events
 
 All available events dispatched by BookStack are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class, which can be found within the file `app/Theming/ThemeEvents.php` relative to your root BookStack folder. Alternatively, the events for the latest release can be [seen on GitHub here](https://github.com/BookStackApp/BookStack/blob/release/app/Theming/ThemeEvents.php).
@@ -77,6 +96,33 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
 });
 ```
 
+## Custom Command Registration Example
+
+The logical theme system supports adding custom [artisan commands](https://laravel.com/docs/8.x/artisan) to BookStack.
+These can be registered in your `functions.php` file by calling `Theme::registerCommand($command)`, where `$command` is an instance of `\Symfony\Component\Console\Command\Command`. 
+
+Below is an example of registering a command that could then be ran using `php artisan bookstack:meow` on the command line.
+
+```php
+<?php
+
+use BookStack\Facades\Theme;
+use Illuminate\Console\Command;
+
+class MeowCommand extends Command
+{
+    protected $signature = 'bookstack:meow';
+    protected $description = 'Say meow on the command line';
+
+    public function handle()
+    {
+        $this->line('Meow there!');
+    }
+}
+
+Theme::registerCommand(new MeowCommand);
+```
+
 ## Custom Socialite Service Example
 
 The below shows an example of adding a custom reddit socialite service to BookStack. 
@@ -95,4 +141,18 @@ Theme::listen(ThemeEvents::APP_BOOT, function($app) {
         'name' => 'Reddit',
     ], '\SocialiteProviders\Reddit\RedditExtendSocialite@handle');
 });
+```
+
+In some cases you may need to customize the driver before it performs a redirect. 
+This can be done by providing a callback as a fourth parameter like so:
+
+```php
+Theme::addSocialDriver('reddit', [
+    'client_id' => 'abc123',
+    'client_secret' => 'def456789',
+    'name' => 'Reddit',
+], '\SocialiteProviders\Reddit\RedditExtendSocialite@handle', function($driver) {
+    $driver->with(['prompt' => 'select_account']);
+    $driver->scopes(['open_id']);
+});
 ```
\ No newline at end of file
index 058bd2823b25176b5b8dd7b15b827bc63135bc95..8f3129b229f7dd19c8c6bafc23777882522c7fd3 100644 (file)
@@ -6,6 +6,8 @@ This theme system itself is maintained and supported but usages of this system,
 
 ## Getting Started
 
+*[Video Guide](https://www.youtube.com/watch?v=gLy_2GBse48)*
+
 This makes use of the theme system. Create a folder for your theme within your BookStack `themes` directory. As an example we'll use `my_theme`, so we'd create a `themes/my_theme` folder.
 You'll need to tell BookStack to use your theme via the `APP_THEME` option in your `.env` file. For example: `APP_THEME=my_theme`.
 
@@ -28,4 +30,4 @@ As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<them
 return [
     'search' => 'find',
 ];
-```
\ No newline at end of file
+```
index 7920258944694e6b0d1af757ba9fd3bef5b45e57..13648c12176463301a7835c70ec35205741c335e 100644 (file)
@@ -38,7 +38,10 @@ services:
       - ${DEV_PORT:-8080}:80
     volumes:
       - ./:/app
+      - ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
     entrypoint: /app/dev/docker/entrypoint.app.sh
+    extra_hosts:
+    - "host.docker.internal:host-gateway"
   node:
     image: node:alpine
     working_dir: /app
index 7f83f7160878ace0c3e8ac1e5f6ea6e681eec9fa..24990b6116be0c78346c2dfacdf81994afe5422f 100644 (file)
 {
+  "name": "bookstack",
+  "lockfileVersion": 2,
   "requires": true,
-  "lockfileVersion": 1,
+  "packages": {
+    "": {
+      "dependencies": {
+        "clipboard": "^2.0.8",
+        "codemirror": "^5.65.1",
+        "dropzone": "^5.9.3",
+        "markdown-it": "^12.3.2",
+        "markdown-it-task-lists": "^2.1.1",
+        "sortablejs": "^1.14.0"
+      },
+      "devDependencies": {
+        "chokidar-cli": "^3.0",
+        "esbuild": "0.14.13",
+        "livereload": "^0.9.3",
+        "npm-run-all": "^4.1.5",
+        "punycode": "^2.1.1",
+        "sass": "^1.49.0"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+      "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+      "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
+      "dev": true,
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar-cli": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz",
+      "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==",
+      "dev": true,
+      "dependencies": {
+        "chokidar": "^3.5.2",
+        "lodash.debounce": "^4.0.8",
+        "lodash.throttle": "^4.1.1",
+        "yargs": "^13.3.0"
+      },
+      "bin": {
+        "chokidar": "index.js"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      }
+    },
+    "node_modules/clipboard": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.8.tgz",
+      "integrity": "sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==",
+      "dependencies": {
+        "good-listener": "^1.2.2",
+        "select": "^1.1.2",
+        "tiny-emitter": "^2.0.0"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
+      "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^3.1.0",
+        "strip-ansi": "^5.2.0",
+        "wrap-ansi": "^5.1.0"
+      }
+    },
+    "node_modules/codemirror": {
+      "version": "5.65.1",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.1.tgz",
+      "integrity": "sha512-s6aac+DD+4O2u1aBmdxhB7yz2XU7tG3snOyQ05Kxifahz7hoxnfxIRHxiCSEv3TUC38dIVH8G+lZH9UWSfGQxA=="
+    },
+    "node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+      "dev": true
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+      "dev": true
+    },
+    "node_modules/cross-spawn": {
+      "version": "6.0.5",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+      "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+      "dev": true,
+      "dependencies": {
+        "nice-try": "^1.0.4",
+        "path-key": "^2.0.1",
+        "semver": "^5.5.0",
+        "shebang-command": "^1.2.0",
+        "which": "^1.2.9"
+      },
+      "engines": {
+        "node": ">=4.8"
+      }
+    },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+      "dev": true,
+      "dependencies": {
+        "object-keys": "^1.0.12"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
+    "node_modules/dropzone": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.3.tgz",
+      "integrity": "sha512-Azk8kD/2/nJIuVPK+zQ9sjKMRIpRvNyqn9XwbBHNq+iNuSccbJS6hwm1Woy0pMST0erSo0u4j+KJaodndDk4vA=="
+    },
+    "node_modules/emoji-regex": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+      "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
+      "dev": true
+    },
+    "node_modules/entities": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
+      "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==",
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dev": true,
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "node_modules/es-abstract": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+      "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "es-to-primitive": "^1.2.1",
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.1.1",
+        "get-symbol-description": "^1.0.0",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.2",
+        "internal-slot": "^1.0.3",
+        "is-callable": "^1.2.4",
+        "is-negative-zero": "^2.0.1",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.1",
+        "is-string": "^1.0.7",
+        "is-weakref": "^1.0.1",
+        "object-inspect": "^1.11.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.2",
+        "string.prototype.trimend": "^1.0.4",
+        "string.prototype.trimstart": "^1.0.4",
+        "unbox-primitive": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-to-primitive": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+      "dev": true,
+      "dependencies": {
+        "is-callable": "^1.1.4",
+        "is-date-object": "^1.0.1",
+        "is-symbol": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.13.tgz",
+      "integrity": "sha512-FIxvAdj3i2oHA6ex+E67bG7zlSTO+slt8kU2ogHDgGtrQLy2HNChv3PYjiFTYkt8hZbEAniZCXVeHn+FrHt7dA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "optionalDependencies": {
+        "esbuild-android-arm64": "0.14.13",
+        "esbuild-darwin-64": "0.14.13",
+        "esbuild-darwin-arm64": "0.14.13",
+        "esbuild-freebsd-64": "0.14.13",
+        "esbuild-freebsd-arm64": "0.14.13",
+        "esbuild-linux-32": "0.14.13",
+        "esbuild-linux-64": "0.14.13",
+        "esbuild-linux-arm": "0.14.13",
+        "esbuild-linux-arm64": "0.14.13",
+        "esbuild-linux-mips64le": "0.14.13",
+        "esbuild-linux-ppc64le": "0.14.13",
+        "esbuild-linux-s390x": "0.14.13",
+        "esbuild-netbsd-64": "0.14.13",
+        "esbuild-openbsd-64": "0.14.13",
+        "esbuild-sunos-64": "0.14.13",
+        "esbuild-windows-32": "0.14.13",
+        "esbuild-windows-64": "0.14.13",
+        "esbuild-windows-arm64": "0.14.13"
+      }
+    },
+    "node_modules/esbuild-android-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.13.tgz",
+      "integrity": "sha512-rhtwl+KJ3BzzXkK09N3/YbEF1i5WhriysJEStoeWNBzchx9hlmzyWmDGQQhu56HF78ua3JrVPyLOsdLGvtMvxQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/esbuild-darwin-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.13.tgz",
+      "integrity": "sha512-Fl47xIt5RMu50WIgMU93kwmUUJb+BPuL8R895n/aBNQqavS+KUMpLPoqKGABBV4myfx/fnAD/97X8Gt1C1YW6w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/esbuild-darwin-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.13.tgz",
+      "integrity": "sha512-UttqKRFXsWvuivcyAbFmo54vdkC9Me1ZYQNuoz/uBYDbkb2MgqKYG2+xoVKPBhLvhT0CKM5QGKD81flMH5BE6A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/esbuild-freebsd-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.13.tgz",
+      "integrity": "sha512-dlIhPFSp29Yq2TPh7Cm3/4M0uKjlfvOylHVNCRvRNiOvDbBol6/NZ3kLisczms+Yra0rxVapBPN1oMbSMuts9g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/esbuild-freebsd-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.13.tgz",
+      "integrity": "sha512-bNOHLu7Oq6RwaAMnwPbJ40DVGPl9GlAOnfH/dFZ792f8hFEbopkbtVzo1SU1jjfY3TGLWOgqHNWxPxx1N7Au+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/esbuild-linux-32": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.13.tgz",
+      "integrity": "sha512-WzXyBx6zx16adGi7wPBvH2lRCBzYMcqnBRrJ8ciLIqYyruGvprZocX1nFWfiexjLcFxIElWnMNPX6LG7ULqyXA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-linux-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.13.tgz",
+      "integrity": "sha512-P6OFAfcoUvE7g9h/0UKm3qagvTovwqpCF1wbFLWe/BcCY8BS1bR/+SxUjCeKX2BcpIsg4/43ezHDE/ntg/iOpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-linux-arm": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.13.tgz",
+      "integrity": "sha512-4jmm0UySCg3Wi6FEBS7jpiPb1IyckI5um5kzYRwulHxPzkiokd6cgpcsTakR4/Y84UEicS8LnFAghHhXHZhbFg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-linux-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.13.tgz",
+      "integrity": "sha512-k/uIvmkm4mc7vyMvJVwILgGxi2F+FuvLdmESIIWoHrnxEfEekC5AWpI/R6GQ2OMfp8snebSQLs8KL05QPnt1zA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-linux-mips64le": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.13.tgz",
+      "integrity": "sha512-vwYtgjQ1TRlUGL88km9wH9TjXsdZyZ/Xht1ASptg5XGRlqGquVjLGH11PfLLunoMdkQ0YTXR68b4l5gRfjVbyg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-linux-ppc64le": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.13.tgz",
+      "integrity": "sha512-0KqDSIkZaYugtcdpFCd3eQ38Fg6TzhxmOpkhDIKNTwD/W2RoXeiS+Z4y5yQ3oysb/ySDOxWkwNqTdXS4sz2LdQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-linux-s390x": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.13.tgz",
+      "integrity": "sha512-bG20i7d0CN97fwPN9LaLe64E2IrI0fPZWEcoiff9hzzsvo/fQCx0YjMbPC2T3gqQ48QZRltdU9hQilTjHk3geQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/esbuild-netbsd-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.13.tgz",
+      "integrity": "sha512-jz96PQb0ltqyqLggPpcRbWxzLvWHvrZBHZQyjcOzKRDqg1fR/R1y10b1Cuv84xoIbdAf+ceNUJkMN21FfR9G2g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ]
+    },
+    "node_modules/esbuild-openbsd-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.13.tgz",
+      "integrity": "sha512-bp6zSo3kDCXKPM5MmVUg6DEpt+yXDx37iDGzNTn3Kf9xh6d0cdITxUC4Bx6S3Di79GVYubWs+wNjSRVFIJpryw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/esbuild-sunos-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.13.tgz",
+      "integrity": "sha512-08Fne1T9QHYxUnu55sV9V4i/yECADOaI1zMGET2YUa8SRkib10i80hc89U7U/G02DxpN/KUJMWEGq2wKTn0QFQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ]
+    },
+    "node_modules/esbuild-windows-32": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.13.tgz",
+      "integrity": "sha512-MW3BMIi9+fzTyDdljH0ftfT/qlD3t+aVzle1O+zZ2MgHRMQD20JwWgyqoJXhe6uDVyunrAUbcjH3qTIEZN3isg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/esbuild-windows-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.13.tgz",
+      "integrity": "sha512-d7+0N+EOgBKdi/nMxlQ8QA5xHBlpcLtSrYnHsA+Xp4yZk28dYfRw1+embsHf5uN5/1iPvrJwPrcpgDH1xyy4JA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/esbuild-windows-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.13.tgz",
+      "integrity": "sha512-oX5hmgXk9yNKbb5AxThzRQm/E9kiHyDll7JJeyeT1fuGENTifv33f0INCpjBQ+Ty5ChKc84++ZQTEBwLCA12Kw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+      "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-symbol-description": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+      "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/good-listener": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
+      "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
+      "dependencies": {
+        "delegate": "^3.1.2"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.8",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz",
+      "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==",
+      "dev": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/has-bigints": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+      "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hosted-git-info": {
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true
+    },
+    "node_modules/immutable": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
+      "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==",
+      "dev": true
+    },
+    "node_modules/internal-slot": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+      "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+      "dev": true,
+      "dependencies": {
+        "get-intrinsic": "^1.1.0",
+        "has": "^1.0.3",
+        "side-channel": "^1.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
+      "dev": true
+    },
+    "node_modules/is-bigint": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+      "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+      "dev": true,
+      "dependencies": {
+        "has-bigints": "^1.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+      "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+      "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.0.tgz",
+      "integrity": "sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==",
+      "dev": true,
+      "dependencies": {
+        "has": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+      "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+      "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
+      "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
+      "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regex": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+      "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+      "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-symbol": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+      "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakref": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz",
+      "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "node_modules/json-parse-better-errors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+      "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+      "dev": true
+    },
+    "node_modules/linkify-it": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz",
+      "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==",
+      "dependencies": {
+        "uc.micro": "^1.0.1"
+      }
+    },
+    "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,
+      "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.3.2",
+      "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.3.2.tgz",
+      "integrity": "sha512-w677WnINxFkuixAoUEXOStewzLYGI76XVag+0JWMMEyjJQKs0ibWZMxkTlB96Lm3EjZ7IeOxVziBEbtxVQqQZA==",
+      "dev": 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",
+      "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.2",
+        "parse-json": "^4.0.0",
+        "pify": "^3.0.0",
+        "strip-bom": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+      "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^3.0.0",
+        "path-exists": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/lodash.debounce": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
+      "dev": true
+    },
+    "node_modules/lodash.throttle": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+      "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=",
+      "dev": true
+    },
+    "node_modules/markdown-it": {
+      "version": "12.3.2",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
+      "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "~2.1.0",
+        "linkify-it": "^3.0.1",
+        "mdurl": "^1.0.1",
+        "uc.micro": "^1.0.5"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.js"
+      }
+    },
+    "node_modules/markdown-it-task-lists": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
+      "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
+    },
+    "node_modules/mdurl": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+      "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
+    },
+    "node_modules/memorystream": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
+      "integrity": "sha1-htcJCzDORV1j+64S3aUaR93K+bI=",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.10.0"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/nice-try": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+      "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+      "dev": true
+    },
+    "node_modules/normalize-package-data": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+      "dev": true,
+      "dependencies": {
+        "hosted-git-info": "^2.1.4",
+        "resolve": "^1.10.0",
+        "semver": "2 || 3 || 4 || 5",
+        "validate-npm-package-license": "^3.0.1"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/npm-run-all": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz",
+      "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "chalk": "^2.4.1",
+        "cross-spawn": "^6.0.5",
+        "memorystream": "^0.3.1",
+        "minimatch": "^3.0.4",
+        "pidtree": "^0.3.0",
+        "read-pkg": "^3.0.0",
+        "shell-quote": "^1.6.1",
+        "string.prototype.padend": "^3.0.0"
+      },
+      "bin": {
+        "npm-run-all": "bin/npm-run-all/index.js",
+        "run-p": "bin/run-p/index.js",
+        "run-s": "bin/run-s/index.js"
+      },
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz",
+      "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+      "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "has-symbols": "^1.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "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
+    },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+      "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-json": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+      "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
+      "dev": true,
+      "dependencies": {
+        "error-ex": "^1.3.1",
+        "json-parse-better-errors": "^1.0.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+      "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+      "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/path-type": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+      "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+      "dev": true,
+      "dependencies": {
+        "pify": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
+      "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pidtree": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz",
+      "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==",
+      "dev": true,
+      "bin": {
+        "pidtree": "bin/pidtree.js"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/pify": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+      "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/read-pkg": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
+      "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=",
+      "dev": true,
+      "dependencies": {
+        "load-json-file": "^4.0.0",
+        "normalize-package-data": "^2.3.2",
+        "path-type": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "dev": true
+    },
+    "node_modules/resolve": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/sass": {
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.0.tgz",
+      "integrity": "sha512-TVwVdNDj6p6b4QymJtNtRS2YtLJ/CqZriGg0eIAbAKMlN8Xy6kbv33FsEZSF7FufFFM705SQviHjjThfaQ4VNw==",
+      "dev": true,
+      "dependencies": {
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=8.9.0"
+      }
+    },
+    "node_modules/select": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
+      "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0="
+    },
+    "node_modules/semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
+      "dev": true
+    },
+    "node_modules/shebang-command": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+      "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+      "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/shell-quote": {
+      "version": "1.7.3",
+      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz",
+      "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==",
+      "dev": true
+    },
+    "node_modules/side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
+    "node_modules/source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/spdx-correct": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+      "dev": true,
+      "dependencies": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true
+    },
+    "node_modules/spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "dependencies": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-license-ids": {
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz",
+      "integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==",
+      "dev": true
+    },
+    "node_modules/string-width": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+      "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^7.0.1",
+        "is-fullwidth-code-point": "^2.0.0",
+        "strip-ansi": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/string.prototype.padend": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz",
+      "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimend": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+      "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimstart": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+      "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+      "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/tiny-emitter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/uc.micro": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+      "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
+    },
+    "node_modules/unbox-primitive": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+      "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "has-bigints": "^1.0.1",
+        "has-symbols": "^1.0.2",
+        "which-boxed-primitive": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "dependencies": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+      "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "which": "bin/which"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+      "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+      "dev": true,
+      "dependencies": {
+        "is-bigint": "^1.0.1",
+        "is-boolean-object": "^1.1.0",
+        "is-number-object": "^1.0.4",
+        "is-string": "^1.0.5",
+        "is-symbol": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-module": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+      "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
+      "dev": true
+    },
+    "node_modules/wrap-ansi": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
+      "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.0",
+        "string-width": "^3.0.0",
+        "strip-ansi": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ws": {
+      "version": "7.5.5",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
+      "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
+      "dev": true,
+      "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/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "dev": true
+    },
+    "node_modules/yargs": {
+      "version": "13.3.2",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz",
+      "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^5.0.0",
+        "find-up": "^3.0.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^3.0.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^13.1.2"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "13.1.2",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz",
+      "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      }
+    }
+  },
   "dependencies": {
     "ansi-regex": {
       "version": "4.1.0",
       }
     },
     "anymatch": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
-      "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
       "dev": true,
       "requires": {
         "normalize-path": "^3.0.0",
       }
     },
     "argparse": {
-      "version": "1.0.10",
-      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
-      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
-      "requires": {
-        "sprintf-js": "~1.0.2"
-      }
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
     },
     "balanced-match": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
-      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "dev": true
     },
     "binary-extensions": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz",
-      "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
       "dev": true
     },
     "brace-expansion": {
         "fill-range": "^7.0.1"
       }
     },
+    "call-bind": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.0.2"
+      }
+    },
     "camelcase": {
       "version": "5.3.1",
       "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
       }
     },
     "chokidar": {
-      "version": "3.4.2",
-      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz",
-      "integrity": "sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A==",
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+      "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
       "dev": true,
       "requires": {
-        "anymatch": "~3.1.1",
+        "anymatch": "~3.1.2",
         "braces": "~3.0.2",
-        "fsevents": "~2.1.2",
-        "glob-parent": "~5.1.0",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
         "is-binary-path": "~2.1.0",
         "is-glob": "~4.0.1",
         "normalize-path": "~3.0.0",
-        "readdirp": "~3.4.0"
+        "readdirp": "~3.6.0"
       }
     },
     "chokidar-cli": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-2.1.0.tgz",
-      "integrity": "sha512-6n21AVpW6ywuEPoxJcLXMA2p4T+SLjWsXKny/9yTWFz0kKxESI3eUylpeV97LylING/27T/RVTY0f2/0QaWq9Q==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz",
+      "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==",
       "dev": true,
       "requires": {
-        "chokidar": "^3.2.3",
+        "chokidar": "^3.5.2",
         "lodash.debounce": "^4.0.8",
         "lodash.throttle": "^4.1.1",
         "yargs": "^13.3.0"
       }
     },
     "codemirror": {
-      "version": "5.60.0",
-      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.60.0.tgz",
-      "integrity": "sha512-AEL7LhFOlxPlCL8IdTcJDblJm8yrAGib7I+DErJPdZd4l6imx8IMgKK3RblVgBQqz3TZJR4oknQ03bz+uNjBYA=="
+      "version": "5.65.1",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.1.tgz",
+      "integrity": "sha512-s6aac+DD+4O2u1aBmdxhB7yz2XU7tG3snOyQ05Kxifahz7hoxnfxIRHxiCSEv3TUC38dIVH8G+lZH9UWSfGQxA=="
     },
     "color-convert": {
       "version": "1.9.3",
       "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
     },
     "dropzone": {
-      "version": "5.9.2",
-      "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.2.tgz",
-      "integrity": "sha512-5t2z51DzIsWDbTpwcJIvUlwxBbvcwdCApz0yb9ecKJwG155Xm92KMEZmHW1B0MzoXOKvFwdd0nPu5cpeVcvPHQ=="
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.3.tgz",
+      "integrity": "sha512-Azk8kD/2/nJIuVPK+zQ9sjKMRIpRvNyqn9XwbBHNq+iNuSccbJS6hwm1Woy0pMST0erSo0u4j+KJaodndDk4vA=="
     },
     "emoji-regex": {
       "version": "7.0.3",
       "dev": true
     },
     "entities": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
-      "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
+      "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
     },
     "error-ex": {
       "version": "1.3.2",
       }
     },
     "es-abstract": {
-      "version": "1.17.6",
-      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz",
-      "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==",
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+      "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
       "dev": true,
       "requires": {
+        "call-bind": "^1.0.2",
         "es-to-primitive": "^1.2.1",
         "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.1.1",
+        "get-symbol-description": "^1.0.0",
         "has": "^1.0.3",
-        "has-symbols": "^1.0.1",
-        "is-callable": "^1.2.0",
-        "is-regex": "^1.1.0",
-        "object-inspect": "^1.7.0",
+        "has-symbols": "^1.0.2",
+        "internal-slot": "^1.0.3",
+        "is-callable": "^1.2.4",
+        "is-negative-zero": "^2.0.1",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.1",
+        "is-string": "^1.0.7",
+        "is-weakref": "^1.0.1",
+        "object-inspect": "^1.11.0",
         "object-keys": "^1.1.1",
-        "object.assign": "^4.1.0",
-        "string.prototype.trimend": "^1.0.1",
-        "string.prototype.trimstart": "^1.0.1"
+        "object.assign": "^4.1.2",
+        "string.prototype.trimend": "^1.0.4",
+        "string.prototype.trimstart": "^1.0.4",
+        "unbox-primitive": "^1.0.1"
       }
     },
     "es-to-primitive": {
       }
     },
     "esbuild": {
-      "version": "0.7.8",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.7.8.tgz",
-      "integrity": "sha512-6UT1nZB+8ja5avctUC6d3kGOUAhy6/ZYHljL4nk3++1ipadghBhUCAcwsTHsmUvdu04CcGKzo13mE+ZQ2O3zrA==",
-      "dev": true
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.13.tgz",
+      "integrity": "sha512-FIxvAdj3i2oHA6ex+E67bG7zlSTO+slt8kU2ogHDgGtrQLy2HNChv3PYjiFTYkt8hZbEAniZCXVeHn+FrHt7dA==",
+      "dev": true,
+      "requires": {
+        "esbuild-android-arm64": "0.14.13",
+        "esbuild-darwin-64": "0.14.13",
+        "esbuild-darwin-arm64": "0.14.13",
+        "esbuild-freebsd-64": "0.14.13",
+        "esbuild-freebsd-arm64": "0.14.13",
+        "esbuild-linux-32": "0.14.13",
+        "esbuild-linux-64": "0.14.13",
+        "esbuild-linux-arm": "0.14.13",
+        "esbuild-linux-arm64": "0.14.13",
+        "esbuild-linux-mips64le": "0.14.13",
+        "esbuild-linux-ppc64le": "0.14.13",
+        "esbuild-linux-s390x": "0.14.13",
+        "esbuild-netbsd-64": "0.14.13",
+        "esbuild-openbsd-64": "0.14.13",
+        "esbuild-sunos-64": "0.14.13",
+        "esbuild-windows-32": "0.14.13",
+        "esbuild-windows-64": "0.14.13",
+        "esbuild-windows-arm64": "0.14.13"
+      }
+    },
+    "esbuild-android-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.13.tgz",
+      "integrity": "sha512-rhtwl+KJ3BzzXkK09N3/YbEF1i5WhriysJEStoeWNBzchx9hlmzyWmDGQQhu56HF78ua3JrVPyLOsdLGvtMvxQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-darwin-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.13.tgz",
+      "integrity": "sha512-Fl47xIt5RMu50WIgMU93kwmUUJb+BPuL8R895n/aBNQqavS+KUMpLPoqKGABBV4myfx/fnAD/97X8Gt1C1YW6w==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-darwin-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.13.tgz",
+      "integrity": "sha512-UttqKRFXsWvuivcyAbFmo54vdkC9Me1ZYQNuoz/uBYDbkb2MgqKYG2+xoVKPBhLvhT0CKM5QGKD81flMH5BE6A==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-freebsd-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.13.tgz",
+      "integrity": "sha512-dlIhPFSp29Yq2TPh7Cm3/4M0uKjlfvOylHVNCRvRNiOvDbBol6/NZ3kLisczms+Yra0rxVapBPN1oMbSMuts9g==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-freebsd-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.13.tgz",
+      "integrity": "sha512-bNOHLu7Oq6RwaAMnwPbJ40DVGPl9GlAOnfH/dFZ792f8hFEbopkbtVzo1SU1jjfY3TGLWOgqHNWxPxx1N7Au+g==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-32": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.13.tgz",
+      "integrity": "sha512-WzXyBx6zx16adGi7wPBvH2lRCBzYMcqnBRrJ8ciLIqYyruGvprZocX1nFWfiexjLcFxIElWnMNPX6LG7ULqyXA==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.13.tgz",
+      "integrity": "sha512-P6OFAfcoUvE7g9h/0UKm3qagvTovwqpCF1wbFLWe/BcCY8BS1bR/+SxUjCeKX2BcpIsg4/43ezHDE/ntg/iOpw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-arm": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.13.tgz",
+      "integrity": "sha512-4jmm0UySCg3Wi6FEBS7jpiPb1IyckI5um5kzYRwulHxPzkiokd6cgpcsTakR4/Y84UEicS8LnFAghHhXHZhbFg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.13.tgz",
+      "integrity": "sha512-k/uIvmkm4mc7vyMvJVwILgGxi2F+FuvLdmESIIWoHrnxEfEekC5AWpI/R6GQ2OMfp8snebSQLs8KL05QPnt1zA==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-mips64le": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.13.tgz",
+      "integrity": "sha512-vwYtgjQ1TRlUGL88km9wH9TjXsdZyZ/Xht1ASptg5XGRlqGquVjLGH11PfLLunoMdkQ0YTXR68b4l5gRfjVbyg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-ppc64le": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.13.tgz",
+      "integrity": "sha512-0KqDSIkZaYugtcdpFCd3eQ38Fg6TzhxmOpkhDIKNTwD/W2RoXeiS+Z4y5yQ3oysb/ySDOxWkwNqTdXS4sz2LdQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-linux-s390x": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.13.tgz",
+      "integrity": "sha512-bG20i7d0CN97fwPN9LaLe64E2IrI0fPZWEcoiff9hzzsvo/fQCx0YjMbPC2T3gqQ48QZRltdU9hQilTjHk3geQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-netbsd-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.13.tgz",
+      "integrity": "sha512-jz96PQb0ltqyqLggPpcRbWxzLvWHvrZBHZQyjcOzKRDqg1fR/R1y10b1Cuv84xoIbdAf+ceNUJkMN21FfR9G2g==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-openbsd-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.13.tgz",
+      "integrity": "sha512-bp6zSo3kDCXKPM5MmVUg6DEpt+yXDx37iDGzNTn3Kf9xh6d0cdITxUC4Bx6S3Di79GVYubWs+wNjSRVFIJpryw==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-sunos-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.13.tgz",
+      "integrity": "sha512-08Fne1T9QHYxUnu55sV9V4i/yECADOaI1zMGET2YUa8SRkib10i80hc89U7U/G02DxpN/KUJMWEGq2wKTn0QFQ==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-windows-32": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.13.tgz",
+      "integrity": "sha512-MW3BMIi9+fzTyDdljH0ftfT/qlD3t+aVzle1O+zZ2MgHRMQD20JwWgyqoJXhe6uDVyunrAUbcjH3qTIEZN3isg==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-windows-64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.13.tgz",
+      "integrity": "sha512-d7+0N+EOgBKdi/nMxlQ8QA5xHBlpcLtSrYnHsA+Xp4yZk28dYfRw1+embsHf5uN5/1iPvrJwPrcpgDH1xyy4JA==",
+      "dev": true,
+      "optional": true
+    },
+    "esbuild-windows-arm64": {
+      "version": "0.14.13",
+      "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.13.tgz",
+      "integrity": "sha512-oX5hmgXk9yNKbb5AxThzRQm/E9kiHyDll7JJeyeT1fuGENTifv33f0INCpjBQ+Ty5ChKc84++ZQTEBwLCA12Kw==",
+      "dev": true,
+      "optional": true
     },
     "escape-string-regexp": {
       "version": "1.0.5",
       }
     },
     "fsevents": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
-      "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
       "dev": true,
       "optional": true
     },
       "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
       "dev": true
     },
+    "get-intrinsic": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1"
+      }
+    },
+    "get-symbol-description": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+      "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.1"
+      }
+    },
     "glob-parent": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
-      "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
       "dev": true,
       "requires": {
         "is-glob": "^4.0.1"
       }
     },
     "graceful-fs": {
-      "version": "4.2.4",
-      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
-      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
+      "version": "4.2.8",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz",
+      "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==",
       "dev": true
     },
     "has": {
         "function-bind": "^1.1.1"
       }
     },
+    "has-bigints": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+      "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+      "dev": true
+    },
     "has-flag": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
       "dev": true
     },
     "has-symbols": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
-      "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
       "dev": true
     },
+    "has-tostringtag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+      "dev": true,
+      "requires": {
+        "has-symbols": "^1.0.2"
+      }
+    },
     "hosted-git-info": {
-      "version": "2.8.8",
-      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
-      "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true
+    },
+    "immutable": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
+      "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==",
       "dev": true
     },
+    "internal-slot": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+      "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+      "dev": true,
+      "requires": {
+        "get-intrinsic": "^1.1.0",
+        "has": "^1.0.3",
+        "side-channel": "^1.0.4"
+      }
+    },
     "is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
       "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
       "dev": true
     },
+    "is-bigint": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+      "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+      "dev": true,
+      "requires": {
+        "has-bigints": "^1.0.1"
+      }
+    },
     "is-binary-path": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
         "binary-extensions": "^2.0.0"
       }
     },
+    "is-boolean-object": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+      "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      }
+    },
     "is-callable": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz",
-      "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==",
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+      "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
       "dev": true
     },
+    "is-core-module": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.0.tgz",
+      "integrity": "sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==",
+      "dev": true,
+      "requires": {
+        "has": "^1.0.3"
+      }
+    },
     "is-date-object": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
-      "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==",
-      "dev": true
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+      "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
+      }
     },
     "is-extglob": {
       "version": "2.1.1",
       "dev": true
     },
     "is-glob": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
-      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
       "dev": true,
       "requires": {
         "is-extglob": "^2.1.1"
       }
     },
+    "is-negative-zero": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
+      "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==",
+      "dev": true
+    },
     "is-number": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
       "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
       "dev": true
     },
+    "is-number-object": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
+      "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
+      }
+    },
     "is-regex": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz",
-      "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
       "dev": true,
       "requires": {
-        "has-symbols": "^1.0.1"
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-shared-array-buffer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+      "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+      "dev": true
+    },
+    "is-string": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+      "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
       }
     },
     "is-symbol": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
-      "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+      "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
       "dev": true,
       "requires": {
-        "has-symbols": "^1.0.1"
+        "has-symbols": "^1.0.2"
+      }
+    },
+    "is-weakref": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz",
+      "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0"
       }
     },
     "isexe": {
       "dev": true
     },
     "linkify-it": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz",
-      "integrity": "sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz",
+      "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==",
       "requires": {
         "uc.micro": "^1.0.1"
       }
         "livereload-js": "^3.3.1",
         "opts": ">= 1.2.0",
         "ws": "^7.4.3"
-      },
-      "dependencies": {
-        "chokidar": {
-          "version": "3.5.1",
-          "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
-          "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
-          "dev": true,
-          "requires": {
-            "anymatch": "~3.1.1",
-            "braces": "~3.0.2",
-            "fsevents": "~2.3.1",
-            "glob-parent": "~5.1.0",
-            "is-binary-path": "~2.1.0",
-            "is-glob": "~4.0.1",
-            "normalize-path": "~3.0.0",
-            "readdirp": "~3.5.0"
-          }
-        },
-        "fsevents": {
-          "version": "2.3.2",
-          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-          "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-          "dev": true,
-          "optional": true
-        },
-        "readdirp": {
-          "version": "3.5.0",
-          "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz",
-          "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==",
-          "dev": true,
-          "requires": {
-            "picomatch": "^2.2.1"
-          }
-        }
       }
     },
     "livereload-js": {
       "dev": true
     },
     "markdown-it": {
-      "version": "11.0.1",
-      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz",
-      "integrity": "sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ==",
+      "version": "12.3.2",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
+      "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
       "requires": {
-        "argparse": "^1.0.7",
-        "entities": "~2.0.0",
+        "argparse": "^2.0.1",
+        "entities": "~2.1.0",
         "linkify-it": "^3.0.1",
         "mdurl": "^1.0.1",
         "uc.micro": "^1.0.5"
       }
     },
     "object-inspect": {
-      "version": "1.8.0",
-      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz",
-      "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==",
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz",
+      "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==",
       "dev": true
     },
     "object-keys": {
       "dev": true
     },
     "object.assign": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
-      "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+      "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
       "dev": true,
       "requires": {
-        "define-properties": "^1.1.2",
-        "function-bind": "^1.1.1",
-        "has-symbols": "^1.0.0",
-        "object-keys": "^1.0.11"
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "has-symbols": "^1.0.1",
+        "object-keys": "^1.1.1"
       }
     },
     "opts": {
       "dev": true
     },
     "path-parse": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
-      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
       "dev": true
     },
     "path-type": {
       }
     },
     "picomatch": {
-      "version": "2.2.2",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
-      "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
+      "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==",
       "dev": true
     },
     "pidtree": {
       }
     },
     "readdirp": {
-      "version": "3.4.0",
-      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
-      "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
       "dev": true,
       "requires": {
         "picomatch": "^2.2.1"
       "dev": true
     },
     "resolve": {
-      "version": "1.17.0",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
-      "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
       "dev": true,
       "requires": {
+        "is-core-module": "^2.2.0",
         "path-parse": "^1.0.6"
       }
     },
     "sass": {
-      "version": "1.32.8",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.32.8.tgz",
-      "integrity": "sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ==",
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.0.tgz",
+      "integrity": "sha512-TVwVdNDj6p6b4QymJtNtRS2YtLJ/CqZriGg0eIAbAKMlN8Xy6kbv33FsEZSF7FufFFM705SQviHjjThfaQ4VNw==",
       "dev": true,
       "requires": {
-        "chokidar": ">=2.0.0 <4.0.0"
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
       }
     },
     "select": {
       "dev": true
     },
     "shell-quote": {
-      "version": "1.7.2",
-      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz",
-      "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==",
+      "version": "1.7.3",
+      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz",
+      "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==",
       "dev": true
     },
+    "side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      }
+    },
     "sortablejs": {
-      "version": "1.13.0",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.13.0.tgz",
-      "integrity": "sha512-RBJirPY0spWCrU5yCmWM1eFs/XgX2J5c6b275/YyxFRgnzPhKl/TDeU2hNR8Dt7ITq66NRPM4UlOt+e5O4CFHg=="
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
+    "source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+      "dev": true
     },
     "spdx-correct": {
       "version": "3.1.1",
       }
     },
     "spdx-license-ids": {
-      "version": "3.0.5",
-      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz",
-      "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==",
+      "version": "3.0.10",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz",
+      "integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==",
       "dev": true
     },
-    "sprintf-js": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
-    },
     "string-width": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
       }
     },
     "string.prototype.padend": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.0.tgz",
-      "integrity": "sha512-3aIv8Ffdp8EZj8iLwREGpQaUZiPyrWrpzMBHvkiSW/bK/EGve9np07Vwy7IJ5waydpGXzQZu/F8Oze2/IWkBaA==",
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz",
+      "integrity": "sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==",
       "dev": true,
       "requires": {
+        "call-bind": "^1.0.2",
         "define-properties": "^1.1.3",
-        "es-abstract": "^1.17.0-next.1"
+        "es-abstract": "^1.19.1"
       }
     },
     "string.prototype.trimend": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
-      "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+      "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
       "dev": true,
       "requires": {
-        "define-properties": "^1.1.3",
-        "es-abstract": "^1.17.5"
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
       }
     },
     "string.prototype.trimstart": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz",
-      "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+      "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
       "dev": true,
       "requires": {
-        "define-properties": "^1.1.3",
-        "es-abstract": "^1.17.5"
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
       }
     },
     "strip-ansi": {
       "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
       "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
     },
+    "unbox-primitive": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+      "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "has-bigints": "^1.0.1",
+        "has-symbols": "^1.0.2",
+        "which-boxed-primitive": "^1.0.2"
+      }
+    },
     "validate-npm-package-license": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
         "isexe": "^2.0.0"
       }
     },
+    "which-boxed-primitive": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+      "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+      "dev": true,
+      "requires": {
+        "is-bigint": "^1.0.1",
+        "is-boolean-object": "^1.1.0",
+        "is-number-object": "^1.0.4",
+        "is-string": "^1.0.5",
+        "is-symbol": "^1.0.3"
+      }
+    },
     "which-module": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
       }
     },
     "ws": {
-      "version": "7.4.4",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.4.tgz",
-      "integrity": "sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==",
-      "dev": true
+      "version": "7.5.5",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
+      "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
+      "dev": true,
+      "requires": {}
     },
     "y18n": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz",
-      "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
       "dev": true
     },
     "yargs": {
index 503abd44fda5b9a12b15ca3987aee96367757f5e..23fc902eba66222bbfee848641cab77493fb7e53 100644 (file)
     "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
   },
   "devDependencies": {
-    "chokidar-cli": "^2.1.0",
-    "esbuild": "0.7.8",
+    "chokidar-cli": "^3.0",
+    "esbuild": "0.14.13",
     "livereload": "^0.9.3",
     "npm-run-all": "^4.1.5",
     "punycode": "^2.1.1",
-    "sass": "^1.32.8"
+    "sass": "^1.49.0"
   },
   "dependencies": {
     "clipboard": "^2.0.8",
-    "codemirror": "^5.60.0",
-    "dropzone": "^5.9.2",
-    "markdown-it": "^11.0.1",
+    "codemirror": "^5.65.1",
+    "dropzone": "^5.9.3",
+    "markdown-it": "^12.3.2",
     "markdown-it-task-lists": "^2.1.1",
-    "sortablejs": "^1.13.0"
+    "sortablejs": "^1.14.0"
   }
 }
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644 (file)
index 8d5157d..0000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0"?>
-<ruleset name="BookStack Standard">
-    <!--    Format described at: https://github.com/squizlabs/PHP_CodeSniffer/wiki/Annotated-Ruleset   -->
-    <description>The coding standard for BookStack.</description>
-    <config name="php_version" value="70205"/>
-    <file>./app</file>
-    <exclude-pattern>*/migrations/*</exclude-pattern>
-    <exclude-pattern>*/tests/*</exclude-pattern>
-    <arg value="np"/>
-    <arg name="colors"/>
-    <rule ref="PSR2"/>
-</ruleset>
\ No newline at end of file
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644 (file)
index 0000000..f3aa47e
--- /dev/null
@@ -0,0 +1,23 @@
+includes:
+    - ./vendor/nunomaduro/larastan/extension.neon
+
+parameters:
+
+    paths:
+        - app
+
+    # The level 8 is the highest level
+    level: 1
+
+    phpVersion: 70300
+
+    bootstrapFiles:
+      - bootstrap/phpstan.php
+
+    ignoreErrors:
+    #  - '#PHPDoc tag @throws with type .*?Psr\\SimpleCache\\InvalidArgumentException.*? is not subtype of Throwable#'
+
+    excludePaths:
+        - ./Config/**/*.php
+
+    checkMissingIterableValueType: false
\ No newline at end of file
index 75c89ec335fb8cb1e7e3b42d5a483edf0b77dd25..960f4c4c33df441437fe3f678edf70b19a191b62 100644 (file)
@@ -1,15 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
-         backupGlobals="false"
-         backupStaticAttributes="false"
          bootstrap="vendor/autoload.php"
-         colors="true"
-         convertErrorsToExceptions="true"
-         convertNoticesToExceptions="true"
-         convertWarningsToExceptions="true"
-         processIsolation="false"
-         stopOnFailure="false">
+         colors="true">
   <coverage>
     <include>
       <directory suffix=".php">app/</directory>
@@ -37,6 +30,7 @@
     <server name="LOG_CHANNEL" value="single"/>
     <server name="AUTH_METHOD" value="standard"/>
     <server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
+    <server name="ALLOW_UNTRUSTED_SERVER_FETCHING" value="false"/>
     <server name="AVATAR_URL" value=""/>
     <server name="LDAP_START_TLS" value="false"/>
     <server name="LDAP_VERSION" value="3"/>
index 9d890e90a4ef4cd9ade7b25b434248c63766d3d0..fdf6e720f53d375a0141a72e5850f5f9e0644c2b 100644 (file)
@@ -1,61 +1,56 @@
 <?php
 
-/**
- * Laravel - A PHP Framework For Web Artisans
- *
- * @package  Laravel
- * @author   Taylor Otwell <[email protected]>
- */
+use BookStack\Http\Request;
+use Illuminate\Contracts\Http\Kernel;
 
 define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Register The Auto Loader
+| Check If The Application Is Under Maintenance
 |--------------------------------------------------------------------------
 |
-| Composer provides a convenient, automatically generated class loader for
-| our application. We just need to utilize it! We'll simply require it
-| into the script here so that we don't have to worry about manual
-| loading any of our classes later on. It feels great to relax.
+| If the application is in maintenance / demo mode via the "down" command
+| we will load this file so that any pre-rendered content can be shown
+| instead of starting the framework, which could cause an exception.
 |
 */
 
-require __DIR__.'/../vendor/autoload.php';
+if (file_exists(__DIR__ . '/../storage/framework/maintenance.php')) {
+    require __DIR__ . '/../storage/framework/maintenance.php';
+}
 
 /*
 |--------------------------------------------------------------------------
-| Turn On The Lights
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to illuminate PHP development, so let us turn on the lights.
-| This bootstraps the framework and gets it ready for use, then it
-| will load up this application so that we can run it and send
-| the responses back to the browser and delight our users.
+| Composer provides a convenient, automatically generated class loader for
+| this application. We just need to utilize it! We'll simply require it
+| into the script here so we don't need to manually load our classes.
 |
 */
 
-$app = require_once __DIR__.'/../bootstrap/app.php';
-$app->alias('request', \BookStack\Http\Request::class);
+require __DIR__ . '/../vendor/autoload.php';
 
 /*
 |--------------------------------------------------------------------------
 | Run The Application
 |--------------------------------------------------------------------------
 |
-| Once we have the application, we can handle the incoming request
-| through the kernel, and send the associated response back to
-| the client's browser allowing them to enjoy the creative
-| and wonderful application we have prepared for them.
+| Once we have the application, we can handle the incoming request using
+| the application's HTTP kernel. Then, we will send the response back
+| to this client's browser, allowing them to enjoy our application.
 |
 */
 
-$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
+$app = require_once __DIR__ . '/../bootstrap/app.php';
+$app->alias('request', Request::class);
 
-$response = $kernel->handle(
-    $request = \BookStack\Http\Request::capture()
-);
+$kernel = $app->make(Kernel::class);
 
-$response->send();
+$response = tap($kernel->handle(
+    $request = Request::capture()
+))->send();
 
-$kernel->terminate($request, $response);
\ No newline at end of file
+$kernel->terminate($request, $response);
diff --git a/public/loading_error.png b/public/loading_error.png
new file mode 100644 (file)
index 0000000..4f588fb
Binary files /dev/null and b/public/loading_error.png differ
index eb98ae6d47dc819cd2de34547b8e885e2b599456..a1a4501efffcbaa2f0500f25e16732a2c2ce9eb5 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -1,11 +1,12 @@
 # BookStack
 
 [![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest)
-[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
+[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/development/LICENSE)
 [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
-[![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
 [![Discord](https://img.shields.io/static/v1?label=chat&message=discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
 [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
+[![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
+[![StyleCI](https://github.styleci.io/repos/41589337/shield?style=flat)](https://github.styleci.io/repos/41589337)
 
 A platform for storing and organising information and documentation. Details for BookStack can be found on the official website at https://www.bookstackapp.com/.
 
@@ -13,17 +14,44 @@ A platform for storing and organising information and documentation. Details for
 * [Documentation](https://www.bookstackapp.com/docs)
 * [Demo Instance](https://demo.bookstackapp.com)
     * [Admin Login](https://demo.bookstackapp.com/[email protected]&password=password)
+* [Screenshots](https://www.bookstackapp.com/#screenshots) 
 * [BookStack Blog](https://www.bookstackapp.com/blog)
 * [Issue List](https://github.com/BookStackApp/BookStack/issues)
 * [Discord Chat](https://discord.gg/ztkBqR2)
 
 ## 📚 Project Definition
 
-BookStack is an opinionated wiki system that provides a pleasant and simple out of the box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
+BookStack is an opinionated wiki system that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
 
 BookStack is not designed as an extensible platform to be used for purposes that differ to the statement above.
 
-In regards to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
+In regard to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
+
+## 🌟 Project Sponsors
+
+Shown below are our bronze, silver and gold project sponsors.
+Big thanks to these companies for supporting the project.
+Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
+[View all sponsors](https://github.com/sponsors/ssddanbrown).
+
+#### Silver Sponsors
+
+<table><tbody><tr>
+<td><a href="https://www.diagrams.net/" target="_blank">
+    <img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
+</a></td>
+<td><a href="https://cloudabove.com/hosting" target="_blank">
+    <img height="100" src="https://raw.githubusercontent.com/BookStackApp/website/main/static/images/sponsors/cloudabove.svg" alt="Cloudabove">
+</a></td>
+</tr></tbody></table>
+
+#### Bronze Sponsor
+
+<table><tbody><tr>
+<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
+    <img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
+</a></td>
+</tr></tbody></table>
 
 ## 🛣️ Road Map
 
@@ -40,17 +68,23 @@ Below is a high-level road map view for BookStack to provide a sense of directio
 
 ## 🚀 Release Versioning & Process
 
-BookStack releases are each assigned a version number, such as "v0.25.2", in the format `v<phase>.<feature>.<patch>`. A change only in the `patch` number indicates a fairly minor release that mainly contains fixes and therefore is very unlikely to cause breakages upon update. A change in the `feature` number indicates a release which will generally bring new features in addition to fixes and enhancements. These releases have a small chance of introducing breaking changes upon update so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/). A change in the `phase` indicates a much large change in BookStack that will likely incur breakages requiring manual intervention.
+BookStack releases are each assigned a date-based version number in the format `v<year>.<month>[.<optional_patch_number>]`. For example:
+
+- `v20.12` - New feature released launched during December 2020. 
+- `v21.06.2` - Second patch release upon the June 2021 feature release.
+
+Patch releases are generally fairly minor, primarily intended for fixes and therefore is fairly unlikely to cause breakages upon update.
+Feature releases are generally larger, bringing new features in addition to fixes and enhancements. These releases have a greater chance of introducing breaking changes upon update, so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/).
 
 Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed.
 
-For feature releases, and some patch releases, the release will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
+Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
 
 ## 🛠️ Development & Testing
 
-All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
+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/) v12.0+
+* [Node.js](https://nodejs.org/en/) v14.0+
 
 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:
 
@@ -81,7 +115,8 @@ Once done you can run `php vendor/bin/phpunit` in the application root directory
 
 ### 📜 Code Standards
 
-PHP code within BookStack is generally to [PSR-2](http://www.php-fig.org/psr/psr-2/) standards. From the BookStack root folder you can run `./vendor/bin/phpcs` to check code is formatted correctly and `./vendor/bin/phpcbf` to auto-fix non-PSR-2 code. Please don't auto-fix code unless it's related to changes you've made otherwise you'll likely cause git conflicts.
+PHP code style is enforced automatically [using StyleCI](https://github.styleci.io/repos/41589337). 
+If submitting a PR, any formatting changes to be made will be automatically fixed after merging.  
 
 ### 🐋 Development using Docker
 
@@ -124,6 +159,11 @@ Once the database has been migrated & seeded, you can run the tests like so:
 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.
+
 ## 🌎 Translations
 
 Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time. Crowdin is the preferred way to provide translations, otherwise the raw translations files can be found within the `resources/lang` path.
@@ -138,9 +178,9 @@ Feel free to create issues to request new features or to report bugs & problems.
 
 Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
 
-Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
+Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
 
-The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
+The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/CODE_OF_CONDUCT.md).
 
 ## 🔒 Security
 
@@ -148,7 +188,7 @@ Security information for administering a BookStack instance can be found on the
 
 If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
 
-If you would like to report a security concern in a more confidential manner than via a GitHub issue, You can directly email the lead maintainer [ssddanbrown](https://github.com/ssddanbrown). You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). Alternatively you can send a DM via twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
+If you would like to report a security concern, details of doing so can [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/SECURITY.md).
 
 ## ♿ Accessibility
 
@@ -166,7 +206,7 @@ The BookStack source is provided under the MIT License. The libraries used by, a
 
 The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors).
 
-The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/master/.github/translators.txt).
+The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/development/.github/translators.txt).
 
 These are the great open-source projects used to help build BookStack:
 
@@ -178,13 +218,16 @@ These are the great open-source projects used to help build BookStack:
 * [Dropzone.js](http://www.dropzonejs.com/)
 * [clipboard.js](https://clipboardjs.com/)
 * [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists)
-* [BarryVD](https://github.com/barryvdh)
-    * [Debugbar](https://github.com/barryvdh/laravel-debugbar)
-    * [Dompdf](https://github.com/barryvdh/laravel-dompdf)
-    * [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
-    * [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
+* [BarryVD/Dompdf](https://github.com/barryvdh/laravel-dompdf)
+* [BarryVD/Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
 * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
 * [diagrams.net](https://github.com/jgraph/drawio)
 * [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
 * [League/CommonMark](https://commonmark.thephpleague.com/)
 * [League/Flysystem](https://flysystem.thephpleague.com)
+* [StyleCI](https://styleci.io/)
+* [pragmarx/google2fa](https://github.com/antonioribeiro/google2fa)
+* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode)
+* [phpseclib](https://github.com/phpseclib/phpseclib)
+* [Clockwork](https://github.com/itsgoingd/clockwork)
+* [PHPStan](https://phpstan.org/) & [Larastan](https://github.com/nunomaduro/larastan)
\ No newline at end of file
index 65c9fccff75141eb4b217e60aff02e90ba205b3b..7fbbeb7925094e6af0a20b4eee15f50c1ff70fc1 100644 (file)
@@ -1 +1 @@
-<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><style>.st0{fill:#7289DA;}</style><path class="st0" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path class="st0" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>
\ No newline at end of file
+<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><path fill="#7289DA" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path fill="#7289DA" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>
\ No newline at end of file
index 7bfafd2260109f00d7e8e7408bce210903ca1035..4c1c5a2711049352f7f355312936228cd8f84f5b 100644 (file)
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 266.893 266.895"><path fill="#3C5A99" d="M248.082 262.307c7.854 0 14.223-6.37 14.223-14.225V18.812c0-7.857-6.368-14.224-14.223-14.224H18.812c-7.857 0-14.224 6.367-14.224 14.224v229.27c0 7.855 6.366 14.225 14.224 14.225h229.27z"/><path fill="#FFF" d="M182.41 262.307v-99.803h33.498l5.016-38.895H182.41V98.775c0-11.26 3.126-18.935 19.274-18.935l20.596-.01V45.047c-3.562-.474-15.788-1.533-30.012-1.533-29.695 0-50.025 18.126-50.025 51.413v28.684h-33.585v38.894h33.585v99.803h40.166z"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1365.3 1365.3"><g transform="matrix(1.3333 0 0 -1.3333 0 1365.3)"><g transform="scale(.1)"><path d="m10240 5120c0 2827.7-2292.3 5120-5120 5120s-5120-2292.3-5120-5120c0-2555.5 1872.3-4673.7 4320-5057.8v3577.8h-1300v1480h1300v1128c0 1283.2 764.38 1992 1933.9 1992 560.17 0 1146.1-100 1146.1-100v-1260h-645.62c-636.03 0-834.38-394.67-834.38-799.57v-960.43h1420l-227-1480h-1193v-3577.8c2447.7 384.1 4320 2502.3 4320 5057.8" fill="#1877f2"/><path d="m7113 3640 227 1480h-1420v960.43c0 404.9 198.35 799.57 834.38 799.57h645.62v1260s-585.93 100-1146.1 100c-1169.5 0-1933.9-708.8-1933.9-1992v-1128h-1300v-1480h1300v-3577.8c260.67-40.898 527.84-62.199 800-62.199s539.33 21.301 800 62.199v3577.8h1193" fill="#fff"/></g></g></svg>
\ No newline at end of file
index 5280f971fbb70d2a289a4e8d9e0e21f88744c8ab..98e5a32f5fcba89cea2301c89e0d5ab78c9ea3ed 100644 (file)
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" viewBox="0 0 64 64"><style id="style3">.st0{fill:#ECB32D;} .st1{fill:#63C1A0;} .st2{fill:#E01A59;} .st3{fill:#331433;} .st4{fill:#D62027;} .st5{fill:#89D3DF;} .st6{fill:#258B74;} .st7{fill:#819C3C;}</style><g id="g5"><g id="g7"><path id="path9" fill="#ecb32d" d="M41.478 3.945C40.48.95 37.28-.677 34.288.27c-2.992.997-4.62 4.2-3.674 7.195l14.748 45.383c.997 2.784 4.042 4.36 6.928 3.52 3.044-.893 4.88-4.098 3.884-7.04 0-.104-14.696-45.383-14.696-45.383z" class="st0"/><path id="path11" fill="#63c1a0" d="M18.648 11.352c-.997-2.994-4.2-4.623-7.19-3.677-2.992.998-4.62 4.202-3.674 7.196l14.748 45.39c.997 2.784 4.04 4.36 6.928 3.52 3.044-.894 4.88-4.098 3.883-7.04 0-.105-14.695-45.383-14.695-45.383z" class="st1"/><path id="path13" fill="#e01a59" d="M60.058 41.502c2.99-.998 4.618-4.202 3.674-7.196-.997-2.994-4.2-4.622-7.19-3.677L11.14 45.44c-2.78.998-4.356 4.045-3.516 6.934.892 3.046 4.094 4.885 7.033 3.887.104 0 45.398-14.76 45.398-14.76z" class="st2"/><path id="path15" fill="#331433" d="M20.59 54.372c2.94-.946 6.77-2.207 10.864-3.52-.945-2.94-2.204-6.776-3.516-10.873l-10.865 3.514L20.59 54.37z" class="st3"/><path id="path17" fill="#d62027" d="M43.473 46.913c4.094-1.313 7.925-2.574 10.864-3.52-.945-2.94-2.204-6.776-3.516-10.873l-10.86 3.52 3.518 10.873z" class="st4"/><path id="path19" fill="#89d3df" d="M52.605 18.653c2.992-.998 4.62-4.202 3.674-7.196-1-2.994-4.2-4.623-7.19-3.677L3.74 22.54c-2.78.998-4.356 4.045-3.516 6.934.892 3.046 4.094 4.885 7.033 3.887.104 0 45.345-14.703 45.345-14.703z" class="st5"/><path id="path21" fill="#258b74" d="M13.19 31.47c2.94-.946 6.77-2.206 10.864-3.52-1.312-4.097-2.572-7.93-3.517-10.873l-10.864 3.52L13.19 31.47z" class="st6"/><path id="path23" fill="#819c3c" d="M36.02 24.063c4.094-1.313 7.925-2.573 10.864-3.52-1.312-4.096-2.57-7.93-3.516-10.872l-10.864 3.52 3.516 10.877z" class="st7"/></g></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.8 122.8"><style type="text/css">.st0{fill:#E01E5A;} .st1{fill:#36C5F0;} .st2{fill:#2EB67D;} .st3{fill:#ECB22E;}</style><g transform="translate(-73.6,-73.6)"><path class="st0" d="m99.4 151.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9 5.8-12.9 12.9-12.9h12.9z"/><path class="st0" d="m105.9 151.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9z"/><path class="st1" d="m118.8 99.4c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9 12.9 5.8 12.9 12.9v12.9z"/><path class="st1" d="m118.8 105.9c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9h-32.3c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9z"/><path class="st2" d="m170.6 118.8c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9h-12.9z"/><path class="st2" d="m164.1 118.8c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9v-32.3c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9z"/><path class="st3" d="m151.2 170.6c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9v-12.9z"/><path class="st3" d="m151.2 164.1c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9z"/></g></svg>
\ No newline at end of file
index 4c91c86b77e460e8fd9bd1199f36b233fe671a8b..0597dbdf2a03852986c4523edd71ec40fbc0df55 100644 (file)
@@ -1,4 +1,3 @@
 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-    <path d="M0 0h24v24H0z" fill="none"/>
     <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
 </svg>
\ No newline at end of file
diff --git a/resources/icons/leaderboard.svg b/resources/icons/leaderboard.svg
new file mode 100644 (file)
index 0000000..9083330
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="#000000"><g><path d="M7.5,21H2V9h5.5V21z M14.75,3h-5.5v18h5.5V3z M22,11h-5.5v10H22V11z"/></g></svg>
\ No newline at end of file
diff --git a/resources/icons/oidc.svg b/resources/icons/oidc.svg
new file mode 100644 (file)
index 0000000..a9a2994
--- /dev/null
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M0 0h24v24H0z" fill="none"/>
+    <path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
+</svg>
\ No newline at end of file
index 0e3f1687911a6316e4b7de75c05d85c16770659a..a7667e48f1a4c68e1168a0567912bf2279bac52f 100644 (file)
@@ -1,4 +1,3 @@
 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-    <path d="M0 0h24v24H0z" fill="none"/>
     <path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
 </svg>
\ No newline at end of file
diff --git a/resources/icons/star-outline.svg b/resources/icons/star-outline.svg
new file mode 100644 (file)
index 0000000..4e83ab4
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></svg>
\ No newline at end of file
index 73e328cee060851823187396a8bccc2aee41bcc0..1d92d2b1849ec6d9ea1d28f904c8644d0e2de055 100644 (file)
@@ -1,4 +1 @@
-<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-    <path d="M0 0h24v24H0z" fill="none"/>
-    <path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"/>
-</svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><path d="M21.41,11.41l-8.83-8.83C12.21,2.21,11.7,2,11.17,2H4C2.9,2,2,2.9,2,4v7.17c0,0.53,0.21,1.04,0.59,1.41l8.83,8.83 c0.78,0.78,2.05,0.78,2.83,0l7.17-7.17C22.2,13.46,22.2,12.2,21.41,11.41z M6.5,8C5.67,8,5,7.33,5,6.5S5.67,5,6.5,5S8,5.67,8,6.5 S7.33,8,6.5,8z"/></svg>
\ No newline at end of file
diff --git a/resources/icons/webhooks.svg b/resources/icons/webhooks.svg
new file mode 100644 (file)
index 0000000..fff0814
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>
\ No newline at end of file
diff --git a/resources/js/components/attachments-list.js b/resources/js/components/attachments-list.js
new file mode 100644 (file)
index 0000000..34979c2
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * Attachments List
+ * Adds '?open=true' query to file attachment links
+ * when ctrl/cmd is pressed down.
+ * @extends {Component}
+ */
+class AttachmentsList {
+
+    setup() {
+        this.container = this.$el;
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        const isExpectedKey = (event) => event.key === 'Control' || event.key === 'Meta';
+        window.addEventListener('keydown', event => {
+             if (isExpectedKey(event)) {
+                this.addOpenQueryToLinks();
+             }
+        }, {passive: true});
+        window.addEventListener('keyup', event => {
+            if (isExpectedKey(event)) {
+                this.removeOpenQueryFromLinks();
+            }
+        }, {passive: true});
+    }
+
+    addOpenQueryToLinks() {
+        const links = this.container.querySelectorAll('a.attachment-file');
+        for (const link of links) {
+            if (link.href.split('?')[1] !== 'open=true') {
+                link.href = link.href + '?open=true';
+                link.setAttribute('target', '_blank');
+            }
+        }
+    }
+
+    removeOpenQueryFromLinks() {
+        const links = this.container.querySelectorAll('a.attachment-file');
+        for (const link of links) {
+            link.href = link.href.split('?')[0];
+            link.removeAttribute('target');
+        }
+    }
+}
+
+export default AttachmentsList;
\ No newline at end of file
index 8c81aae3c2b338bfb1a49723383e603952e1d878..e2d55f9694ded7b9d938050f563fad4791a93cb4 100644 (file)
@@ -27,6 +27,7 @@ class DropdownSearch {
             this.runLocalSearch(input);
         } else {
             this.toggleLoading(true);
+            this.listContainerElem.innerHTML = '';
             this.runAjaxSearch(input);
         }
     }
index e7273df62b2a13eb173b54df39cec2c77d0443e5..44fdf2d0d3fba800e31bf68364088751186428a0 100644 (file)
@@ -11,6 +11,7 @@ class Dropzone {
         this.url = this.$opts.url;
         this.successMessage = this.$opts.successMessage;
         this.removeMessage = this.$opts.removeMessage;
+        this.uploadLimit = Number(this.$opts.uploadLimit);
         this.uploadLimitMessage = this.$opts.uploadLimitMessage;
         this.timeoutMessage = this.$opts.timeoutMessage;
 
@@ -19,7 +20,7 @@ class Dropzone {
             addRemoveLinks: true,
             dictRemoveFile: this.removeMessage,
             timeout: Number(window.uploadTimeout) || 60000,
-            maxFilesize: Number(window.uploadLimit) || 256,
+            maxFilesize: this.uploadLimit,
             url: this.url,
             withCredentials: true,
             init() {
index 354bf0a86b3262f4a5e14a9e446ea87ee9405c65..3a1442d75150f043b821795d21e709f4578fcccc 100644 (file)
@@ -41,7 +41,9 @@ class EditorToolbox {
             if (cName === tabName) this.contentElements[i].style.display = 'block';
         }
 
-        if (openToolbox) this.elem.classList.add('open');
+        if (openToolbox && !this.elem.classList.contains('open')) {
+            this.toggle();
+        }
     }
 
 }
index 0104eace7065373983792a525c501746616bc2c9..231f1021fcbe1158f7f7ba331276e6ba5ba68135 100644 (file)
@@ -7,6 +7,8 @@ class EntitySelectorPopup {
     setup() {
         this.elem = this.$el;
         this.selectButton = this.$refs.select;
+        this.searchInput = this.$refs.searchInput;
+
         window.EntitySelectorPopup = this;
 
         this.callback = null;
@@ -20,6 +22,7 @@ class EntitySelectorPopup {
     show(callback) {
         this.callback = callback;
         this.elem.components.popup.show();
+        this.searchInput.focus();
     }
 
     hide() {
index c974ab1b0abd818d7be81c81d08ae56cf134cd6c..23a6c4cbb9bc1ffb29120997af3291dd36cb2d31 100644 (file)
@@ -74,6 +74,10 @@ class ImageManager {
 
         this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
 
+        this.listContainer.addEventListener('error', event => {
+            event.target.src = baseUrl('loading_error.png');
+        }, true);
+
         onSelect(this.selectButton, () => {
             if (this.callback) {
                 this.callback(this.lastSelected);
@@ -118,6 +122,9 @@ class ImageManager {
         };
 
         const {data: html} = await window.$http.get(`images/${this.type}`, params);
+        if (params.page === 1) {
+            this.listContainer.innerHTML = '';
+        }
         this.addReturnedHtmlElementsToList(html);
         removeLoading(this.listContainer);
     }
index 91ccdaf3aa6f23fca3a923283634c75eb42a0265..fe348aba758a157562e2d48724f6066c22301d0e 100644 (file)
@@ -2,6 +2,7 @@ import addRemoveRows from "./add-remove-rows.js"
 import ajaxDeleteRow from "./ajax-delete-row.js"
 import ajaxForm from "./ajax-form.js"
 import attachments from "./attachments.js"
+import attachmentsList from "./attachments-list.js"
 import autoSuggest from "./auto-suggest.js"
 import backToTop from "./back-to-top.js"
 import bookSort from "./book-sort.js"
@@ -49,6 +50,7 @@ import templateManager from "./template-manager.js"
 import toggleSwitch from "./toggle-switch.js"
 import triLayout from "./tri-layout.js"
 import userSelect from "./user-select.js"
+import webhookEvents from "./webhook-events";
 import wysiwygEditor from "./wysiwyg-editor.js"
 
 const componentMapping = {
@@ -56,6 +58,7 @@ const componentMapping = {
     "ajax-delete-row": ajaxDeleteRow,
     "ajax-form": ajaxForm,
     "attachments": attachments,
+    "attachments-list": attachmentsList,
     "auto-suggest": autoSuggest,
     "back-to-top": backToTop,
     "book-sort": bookSort,
@@ -103,6 +106,7 @@ const componentMapping = {
     "toggle-switch": toggleSwitch,
     "tri-layout": triLayout,
     "user-select": userSelect,
+    "webhook-events": webhookEvents,
     "wysiwyg-editor": wysiwygEditor,
 };
 
index 78581ec447f5cf099d64d681c976295d3c6875af..a14047d2f430155083b614773abe1a05f246bb38 100644 (file)
@@ -14,6 +14,7 @@ class MarkdownEditor {
         this.pageId = this.$opts.pageId;
         this.textDirection = this.$opts.textDirection;
         this.imageUploadErrorText = this.$opts.imageUploadErrorText;
+        this.serverUploadLimitText = this.$opts.serverUploadLimitText;
 
         this.markdown = new MarkdownIt({html: true});
         this.markdown.use(mdTasksLists, {label: true});
@@ -111,6 +112,11 @@ class MarkdownEditor {
         if (scrollText) {
             this.scrollToText(scrollText);
         }
+
+        // Refresh CodeMirror on container resize
+        const resizeDebounced = debounce(() => code.updateLayout(this.cm), 100, false);
+        const observer = new ResizeObserver(resizeDebounced);
+        observer.observe(this.elem);
     }
 
     // Update the input content and render the display.
@@ -394,8 +400,9 @@ class MarkdownEditor {
     actionInsertImage() {
         const cursorPos = this.cm.getCursor('from');
         window.ImageManager.show(image => {
+            const imageUrl = image.thumbs.display || image.url;
             let selectedText = this.cm.getSelection();
-            let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")";
+            let newText = "[![" + (selectedText || image.name) + "](" + imageUrl + ")](" + image.url + ")";
             this.cm.focus();
             this.cm.replaceSelection(newText);
             this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
@@ -446,8 +453,7 @@ class MarkdownEditor {
                 this.insertDrawing(resp.data, cursorPos);
                 DrawIO.close();
             }).catch(err => {
-                window.$events.emit('error', trans('errors.image_upload_error'));
-                console.log(err);
+                this.handleDrawingUploadError(err);
             });
         });
     }
@@ -491,12 +497,20 @@ class MarkdownEditor {
                 this.cm.focus();
                 DrawIO.close();
             }).catch(err => {
-                window.$events.emit('error', this.imageUploadErrorText);
-                console.log(err);
+                this.handleDrawingUploadError(err);
             });
         });
     }
 
+    handleDrawingUploadError(error) {
+        if (error.status === 413) {
+            window.$events.emit('error', this.serverUploadLimitText);
+        } else {
+            window.$events.emit('error', this.imageUploadErrorText);
+        }
+        console.log(error);
+    }
+
     // Make the editor full screen
     actionFullScreen() {
         const alreadyFullscreen = this.elem.classList.contains('fullscreen');
index f66e23b19923d2814b12ea5e2da844892c563557..5f35e64992c50b793bdebf33edcb46a634fea4b9 100644 (file)
@@ -40,6 +40,7 @@ class PageEditor {
             frequency: 30000,
             last: 0,
         };
+        this.shownWarningsCache = new Set();
 
         if (this.pageId !== 0 && this.draftsEnabled) {
             window.setTimeout(() => {
@@ -119,6 +120,10 @@ class PageEditor {
             }
             this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
             this.autoSave.last = Date.now();
+            if (resp.data.warning && !this.shownWarningsCache.has(resp.data.warning)) {
+                window.$events.emit('warning', resp.data.warning);
+                this.shownWarningsCache.add(resp.data.warning);
+            }
         } catch (err) {
             // Save the editor content in LocalStorage as a last resort, just in case.
             try {
index 97996724235efda29fa1bc67752844da199cf644..aeacae23213bd96b94290ed9ab6a4e9e131a4b78 100644 (file)
@@ -6,7 +6,14 @@
 class SubmitOnChange {
 
     setup() {
-        this.$el.addEventListener('change', () => {
+        this.filter = this.$opts.filter;
+
+        this.$el.addEventListener('change', (event) => {
+
+            if (this.filter && !event.target.matches(this.filter)) {
+                return;
+            }
+
             const form = this.$el.closest('form');
             if (form) {
                 form.submit();
index c2c1f97c3aee5911fdb8c80ce4d63c5609370139..aba43e0a920a5c66b0cdb8097061cec9c039d211 100644 (file)
@@ -3,7 +3,6 @@ import {onChildEvent} from "../services/dom";
 class UserSelect {
 
     setup() {
-
         this.input = this.$refs.input;
         this.userInfoContainer = this.$refs.userInfo;
 
diff --git a/resources/js/components/webhook-events.js b/resources/js/components/webhook-events.js
new file mode 100644 (file)
index 0000000..aa50aa9
--- /dev/null
@@ -0,0 +1,32 @@
+
+/**
+ * Webhook Events
+ * Manages dynamic selection control in the webhook form interface.
+ * @extends {Component}
+ */
+class WebhookEvents {
+
+    setup() {
+        this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]');
+        this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]');
+
+        this.$el.addEventListener('change', event => {
+            if (event.target.checked && event.target === this.allCheckbox) {
+                this.deselectIndividualEvents();
+            } else if (event.target.checked) {
+                this.allCheckbox.checked = false;
+            }
+        });
+    }
+
+    deselectIndividualEvents() {
+        for (const checkbox of this.checkboxes) {
+            if (checkbox !== this.allCheckbox) {
+                checkbox.checked = false;
+            }
+        }
+    }
+
+}
+
+export default WebhookEvents;
\ No newline at end of file
index a44ab1c62bfa67508b63339aec33ccda4ca7e1c8..7a2b6ceba45b4aa492a351c6056c9d05f86d1364 100644 (file)
@@ -283,6 +283,15 @@ function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) {
         const id = "image-" + Math.random().toString(16).slice(2);
         const loadingImage = window.baseUrl('/loading.gif');
 
+        const handleUploadError = (error) => {
+            if (error.status === 413) {
+                window.$events.emit('error', wysiwygComponent.serverUploadLimitText);
+            } else {
+                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
+            }
+            console.log(error);
+        };
+
         // Handle updating an existing image
         if (currentNode) {
             DrawIO.close();
@@ -292,8 +301,7 @@ function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) {
                 pageEditor.dom.setAttrib(imgElem, 'src', img.url);
                 pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
             } catch (err) {
-                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
-                console.log(err);
+                handleUploadError(err);
             }
             return;
         }
@@ -307,8 +315,7 @@ function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) {
                 pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
             } catch (err) {
                 pageEditor.dom.remove(id);
-                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
-                console.log(err);
+                handleUploadError(err);
             }
         }, 5);
     }
@@ -432,6 +439,7 @@ class WysiwygEditor {
         this.pageId = this.$opts.pageId;
         this.textDirection = this.$opts.textDirection;
         this.imageUploadErrorText = this.$opts.imageUploadErrorText;
+        this.serverUploadLimitText = this.$opts.serverUploadLimitText;
         this.isDarkMode = document.documentElement.classList.contains('dark-mode');
 
         this.plugins = "image imagetools table textcolor paste link autolink fullscreen code customhr autosave lists codeeditor media";
@@ -555,8 +563,9 @@ class WysiwygEditor {
                         }
 
                         // Replace the actively selected content with the linked image
+                        const imageUrl = image.thumbs.display || image.url;
                         let html = `<a href="${image.url}" target="_blank">`;
-                        html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
+                        html += `<img src="${imageUrl}" alt="${image.name}">`;
                         html += '</a>';
                         win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
                     }, 'gallery');
@@ -715,8 +724,9 @@ class WysiwygEditor {
                     tooltip: 'Insert an image',
                     onclick: function () {
                         window.ImageManager.show(function (image) {
+                            const imageUrl = image.thumbs.display || image.url;
                             let html = `<a href="${image.url}" target="_blank">`;
-                            html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
+                            html += `<img src="${imageUrl}" alt="${image.name}">`;
                             html += '</a>';
                             editor.execCommand('mceInsertContent', false, html);
                         }, 'gallery');
index 5727cd2b79a06f4e68b83eb68f1e1e34e2424300..d82db52710c9b0b0f15cda09ab0866d688632c49 100644 (file)
@@ -26,6 +26,7 @@ import 'codemirror/mode/rust/rust';
 import 'codemirror/mode/shell/shell';
 import 'codemirror/mode/sql/sql';
 import 'codemirror/mode/toml/toml';
+import 'codemirror/mode/vb/vb';
 import 'codemirror/mode/vbscript/vbscript';
 import 'codemirror/mode/xml/xml';
 import 'codemirror/mode/yaml/yaml';
@@ -87,6 +88,8 @@ const modeMap = {
     sql: 'text/x-sql',
     vbs: 'vbscript',
     vbscript: 'vbscript',
+    'vb.net': 'text/x-vb',
+    vbnet: 'text/x-vb',
     xml: 'xml',
     yaml: 'yaml',
     yml: 'yaml',
@@ -208,9 +211,9 @@ function wysiwygView(elem) {
     const doc = elem.ownerDocument;
     const codeElem = elem.querySelector('code');
 
-    let lang = (elem.className || '').replace('language-', '');
-    if (lang === '' && codeElem) {
-        lang = (codeElem.className || '').replace('language-', '')
+    let lang = getLanguageFromCssClasses(elem.className || '');
+    if (!lang && codeElem) {
+        lang = getLanguageFromCssClasses(codeElem.className || '');
     }
 
     elem.innerHTML = elem.innerHTML.replace(/<br\s*[\/]?>/gi ,'\n');
@@ -225,7 +228,7 @@ function wysiwygView(elem) {
     elem.parentNode.replaceChild(newWrap, elem);
 
     newWrap.appendChild(newTextArea);
-    newWrap.contentEditable = false;
+    newWrap.contentEditable = 'false';
     newTextArea.textContent = content;
 
     let cm = CodeMirror(function(elt) {
@@ -242,6 +245,16 @@ function wysiwygView(elem) {
     return {wrap: newWrap, editor: cm};
 }
 
+/**
+ * Get the code language from the given css classes.
+ * @param {String} classes
+ * @return {String}
+ */
+function getLanguageFromCssClasses(classes) {
+    const langClasses = classes.split(' ').filter(cssClass => cssClass.startsWith('language-'));
+    return (langClasses[0] || '').replace('language-', '');
+}
+
 /**
  * Create a CodeMirror instance to show in the WYSIWYG pop-up editor
  * @param {HTMLElement} elem
index 17e57cd6b9165d567aa09f48197ea95e8b62551f..6e22919fb4d230556d99b6ce6a4382f077866af4 100644 (file)
@@ -1,5 +1,5 @@
 let iFrame = null;
-
+let lastApprovedOrigin;
 let onInit, onSave;
 
 /**
@@ -19,15 +19,22 @@ function show(drawioUrl, onInitCallback, onSaveCallback) {
     iFrame.setAttribute('class', 'fullscreen');
     iFrame.style.backgroundColor = '#FFFFFF';
     document.body.appendChild(iFrame);
+    lastApprovedOrigin = (new URL(drawioUrl)).origin;
 }
 
 function close() {
     drawEventClose();
 }
 
+/**
+ * Receive and handle a message event from the draw.io window.
+ * @param {MessageEvent} event
+ */
 function drawReceive(event) {
     if (!event.data || event.data.length < 1) return;
-    let message = JSON.parse(event.data);
+    if (event.origin !== lastApprovedOrigin) return;
+
+    const message = JSON.parse(event.data);
     if (message.event === 'init') {
         drawEventInit();
     } else if (message.event === 'exit') {
@@ -62,7 +69,7 @@ function drawEventClose() {
 }
 
 function drawPostMessage(data) {
-    iFrame.contentWindow.postMessage(JSON.stringify(data), '*');
+    iFrame.contentWindow.postMessage(JSON.stringify(data), lastApprovedOrigin);
 }
 
 async function upload(imageData, pageUploadedToId) {
index 6dc8a83978dcc9e5b8f2c446fd368c68cf9f0722..996f5a7257d5d46d8f354e8f7d9d53725fff31cc 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'تم إنشاء صفحة',
-    'page_create_notification'    => 'تم إنشاء الصفحة بنجاح',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'تم تحديث الصفحة',
-    'page_update_notification'    => 'تم تحديث الصفحة بنجاح',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'تم حذف الصفحة',
-    'page_delete_notification'    => 'تم حذف الصفحة بنجاح',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'تمت استعادة الصفحة',
-    'page_restore_notification'   => 'تمت استعادة الصفحة بنجاح',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'تم نقل الصفحة',
 
     // Chapters
     'chapter_create'              => 'تم إنشاء فصل',
-    'chapter_create_notification' => 'تم إنشاء فصل بنجاح',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'تم تحديث الفصل',
-    'chapter_update_notification' => 'تم تحديث الفصل بنجاح',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'تم حذف الفصل',
-    'chapter_delete_notification' => 'تم حذف الفصل بنجاح',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'تم نقل الفصل',
 
     // Books
     'book_create'                 => 'تم إنشاء كتاب',
-    'book_create_notification'    => 'تم إنشاء كتاب بنجاح',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'تم تحديث الكتاب',
-    'book_update_notification'    => 'تم تحديث الكتاب بنجاح',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'تم حذف الكتاب',
-    'book_delete_notification'    => 'تم حذف الكتاب بنجاح',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'تم سرد الكتاب',
-    'book_sort_notification'      => 'تمت إعادة سرد الكتاب بنجاح',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'تم إنشاء رف الكتب',
-    'bookshelf_create_notification'    => 'تم إنشاء الرف بنجاح',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'تم تحديث الرف',
-    'bookshelf_update_notification'    => 'تم تحديث الرف بنجاح',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'تم تحديث الرف',
-    'bookshelf_delete_notification'    => 'تم حذف الرف بنجاح',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'تم التعليق',
index 60a7af4114a4c6c43f2302f97f15c199092f98c5..766165961ba5ade08533813d733ae92c963e9a2c 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'البريد الإلكتروني',
     'password' => 'كلمة المرور',
     'password_confirm' => 'تأكيد كلمة المرور',
-    'password_hint' => 'يجب أن تكون أكثر من 7 حروف',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'نسيت كلمة المرور؟',
     'remember_me' => 'تذكرني',
     'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'المجال الخاص بالبريد الإلكتروني لا يملك حق الوصول لهذا التطبيق',
     'register_success' => 'شكراً لإنشاء حسابكم! تم تسجيلكم ودخولكم للحساب الخاص بكم.',
 
-
     // Password Reset
     'reset_password' => 'استعادة كلمة المرور',
     'reset_password_send_instructions' => 'أدخل بريدك الإلكتروني بالأسفل وسيتم إرسال رسالة برابط لاستعادة كلمة المرور.',
@@ -47,8 +46,7 @@ return [
     'reset_password_success' => 'تمت استعادة كلمة المرور بنجاح.',
     'email_reset_subject' => 'استعد كلمة المرور الخاصة بتطبيق :appName',
     'email_reset_text' => 'تم إرسال هذه الرسالة بسبب تلقينا لطلب استعادة كلمة المرور الخاصة بحسابكم.',
-    'email_reset_not_requested' => 'إذا لم يتم طلب استعادة كلمة المرور من قبلكم, فلا حاجة لاتخاذ أية خطوات.',
-
+    'email_reset_not_requested' => 'إذا لم يتم طلب استعادة كلمة المرور من قبلكم، فلا حاجة لاتخاذ أية خطوات.',
 
     // Email Confirmation
     'email_confirm_subject' => 'تأكيد بريدكم الإلكتروني لتطبيق :appName',
@@ -56,22 +54,57 @@ return [
     'email_confirm_text' => 'الرجاء تأكيد بريدكم الإلكتروني بالضغط على الزر أدناه:',
     'email_confirm_action' => 'تأكيد البريد الإلكتروني',
     'email_confirm_send_error' => 'تأكيد البريد الإلكتروني مطلوب ولكن النظام لم يستطع إرسال الرسالة. تواصل مع مشرف النظام للتأكد من إعدادات البريد.',
-    'email_confirm_success' => 'تم تأكيد بريدكم الإلكتروني!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'تمت إعادة إرسال رسالة التأكيد. الرجاء مراجعة صندوق الوارد',
 
     'email_not_confirmed' => 'لم يتم تأكيد البريد الإلكتروني',
     'email_not_confirmed_text' => 'لم يتم بعد تأكيد عنوان البريد الإلكتروني.',
     'email_not_confirmed_click_link' => 'الرجاء الضغط على الرابط المرسل إلى بريدكم الإلكتروني بعد تسجيلكم.',
-    'email_not_confirmed_resend' => 'إذا لم يتم إيجاد الرسالة, بإمكانكم إعادة إرسال رسالة التأكيد عن طريق تعبئة النموذج أدناه.',
+    'email_not_confirmed_resend' => 'إذا لم يتم إيجاد الرسالة، بإمكانكم إعادة إرسال رسالة التأكيد عن طريق تعبئة النموذج أدناه.',
     'email_not_confirmed_resend_button' => 'إعادة إرسال رسالة التأكيد',
 
     // User Invite
-    'user_invite_email_subject' => 'تم دعوتك للإنضمام إلى صفحة الحالة الخاصة بـ :app_name!',
+    'user_invite_email_subject' => 'تمت دعوتك للانضمام إلى صفحة الحالة الخاصة بـ :app_name!',
     'user_invite_email_greeting' => 'تم إنشاء حساب مستخدم لك على %site%.',
     'user_invite_email_text' => 'انقر على الزر أدناه لتعيين كلمة مرور الحساب والحصول على الوصول:',
     'user_invite_email_action' => 'كلمة سر المستخدم',
     'user_invite_page_welcome' => 'مرحبا بكم في :appName!',
     'user_invite_page_text' => 'لإكمال حسابك والحصول على حق الوصول تحتاج إلى تعيين كلمة مرور سيتم استخدامها لتسجيل الدخول إلى :appName في الزيارات المستقبلية.',
     'user_invite_page_confirm_button' => 'تأكيد كلمة المرور',
-    'user_invite_success' => 'مجموعة كلمات المرور، لديك الآن حق الوصول إلى :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'إعداد (تنصيب)',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 130f23655b2abf1d91bc4bd58f476474197bb895..37c43597bf62c60bf7f112095f4c96f229fa5ceb 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'الدور',
     'cover_image' => 'صورة الغلاف',
     'cover_image_description' => 'الصورة يجب أن تكون مقاربة لحجم 440×250 بكسل.',
-    
+
     // Actions
     'actions' => 'إجراءات',
     'view' => 'عرض',
@@ -39,23 +39,31 @@ return [
     'reset' => 'إعادة تعيين',
     'remove' => 'إزالة',
     'add' => 'إضافة',
+    'configure' => 'Configure',
     'fullscreen' => 'شاشة كاملة',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
-    'sort_options' => 'خيارات الترتيب',
-    'sort_direction_toggle' => 'الترتيب وفق الإتجاه',
+    'sort_options' => 'خيارات الفرز',
+    'sort_direction_toggle' => 'الفرز وفق الاتجاه',
     'sort_ascending' => 'فرز تصاعدي',
     'sort_descending' => 'فرز تنازلي',
     'sort_name' => 'الاسم',
-    'sort_default' => 'Default',
+    'sort_default' => 'افتراضي',
     'sort_created_at' => 'تاريخ الإنشاء',
     'sort_updated_at' => 'تاريخ التحديث',
 
     // Misc
-    'deleted_user' => 'حذÙ\81 Ù\85ستخدÙ\85',
+    'deleted_user' => 'اÙ\84Ù\85ستخدÙ\85 Ø§Ù\84Ù\85حذÙ\88Ù\81',
     'no_activity' => 'لا يوجد نشاط لعرضه',
     'no_items' => 'لا توجد عناصر متوفرة',
-    'back_to_top' => 'العودة للبداية',
+    'back_to_top' => 'العودة إلى الأعلى',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'عرض / إخفاء التفاصيل',
     'toggle_thumbnails' => 'عرض / إخفاء الصور المصغرة',
     'details' => 'التفاصيل',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'عرض منسدل',
     'default' => 'افتراضي',
     'breadcrumb' => 'شريط التنقل',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'عرض القائمة',
     'profile_menu' => 'قائمة ملف التعريف',
     'view_profile' => 'عرض الملف الشخصي',
     'edit_profile' => 'تعديل الملف الشخصي',
@@ -74,16 +87,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'معلومات',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'تبويب: إظهار المعلومات الثانوية',
     'tab_content' => 'المحتوى',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'تبويب: إظهار المحتوى الأساسي',
 
     // Email Content
-    'email_action_help' => 'إذا Ù\88اجÙ\87تÙ\83Ù\85 Ù\85Ø´Ù\83Ù\84Ø© Ø¨ضغط زر ":actionText" فبإمكانكم نسخ الرابط أدناه ولصقه بالمتصفح:',
+    'email_action_help' => 'إذا Ù\88اجÙ\87تÙ\83Ù\85 Ù\85Ø´Ù\83Ù\84Ø© Ø¹Ù\86د ضغط زر ":actionText" فبإمكانكم نسخ الرابط أدناه ولصقه بالمتصفح:',
     'email_rights' => 'جميع الحقوق محفوظة',
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'سياسة الخصوصية',
+    'terms_of_service' => 'اتفاقية شروط الخدمة',
 ];
index 422de114b2aba7490926a61b0e77c4de0245da7d..803232b2d00e94706b151da31f4f1647dbebad17 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'المزيد',
     'image_image_name' => 'اسم الصورة',
     'image_delete_used' => 'هذه الصورة مستخدمة بالصفحات أدناه.',
-    'image_delete_confirm_text' => 'هل أنت متأكد من أنك تريد حذف هذه الصورة ؟',
+    'image_delete_confirm_text' => 'هل أنت متأكد من أنك تريد حذف هذه الصورة؟',
     'image_select_image' => 'تحديد الصورة',
     'image_dropzone' => 'قم بإسقاط الصورة أو اضغط هنا للرفع',
     'images_deleted' => 'تم حذف الصور',
index 8771f64eec9435c9a44066cd21be6323e87c119d..3dc4c0473ec3bdebed52281978ed743d3c57dabf 100644 (file)
@@ -11,7 +11,7 @@ return [
     'recently_updated_pages' => 'صفحات حُدثت مؤخراً',
     'recently_created_chapters' => 'فصول أنشئت مؤخراً',
     'recently_created_books' => 'كتب أنشئت مؤخراً',
-    'recently_created_shelves' => 'اÙ\84أرÙ\81Ù\81 Ø§Ù\84Ù\85Ù\86شأة مؤخراً',
+    'recently_created_shelves' => 'أرÙ\81Ù\81 Ø£Ù\86شئت مؤخراً',
     'recently_update' => 'حُدثت مؤخراً',
     'recently_viewed' => 'عُرضت مؤخراً',
     'recent_activity' => 'نشاطات حديثة',
@@ -27,17 +27,20 @@ return [
     'images' => 'صور',
     'my_recent_drafts' => 'مسوداتي الحديثة',
     'my_recently_viewed' => 'ما عرضته مؤخراً',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'لم تستعرض أي صفحات',
-    'no_pages_recently_created' => 'لم يتم إنشاء أي صفحات مؤخراً',
-    'no_pages_recently_updated' => 'لم يتم تحديث أي صفحات مؤخراً',
+    'no_pages_recently_created' => 'لم تنشأ أي صفحات مؤخراً',
+    'no_pages_recently_updated' => 'لم تُحدّث أي صفحات مؤخراً',
     'export' => 'تصدير',
     'export_html' => 'صفحة ويب',
     'export_pdf' => 'ملف PDF',
     'export_text' => 'ملف نص عادي',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'الأذونات',
-    'permissions_intro' => 'في حال التفعيل, ستتم تبدية هذه الأذونات على أذونات الأدوار.',
+    'permissions_intro' => 'عند التفعيل، سوف تأخذ هذه الأذونات أولوية على أي صلاحية أخرى للدور.',
     'permissions_enable' => 'تفعيل الأذونات المخصصة',
     'permissions_save' => 'حفظ الأذونات',
     'permissions_owner' => 'Owner',
@@ -55,8 +58,8 @@ return [
     'search_exact_matches' => 'نتائج مطابقة تماماً',
     'search_tags' => 'بحث الوسوم',
     'search_options' => 'الخيارات',
-    'search_viewed_by_me' => 'تÙ\85 Ø§Ø³ØªØ¹Ø±Ø§Ø¶Ù\87ا من قبلي',
-    'search_not_viewed_by_me' => 'لم يتم استعراضها من قبلي',
+    'search_viewed_by_me' => 'استعرضت من قبلي',
+    'search_not_viewed_by_me' => 'لم تستعرض من قبلي',
     'search_permissions_set' => 'حزمة الأذونات',
     'search_created_by_me' => 'أنشئت بواسطتي',
     'search_updated_by_me' => 'حُدثت بواسطتي',
@@ -74,32 +77,33 @@ return [
     'shelves' => 'الأرفف',
     'x_shelves' => ':count رف|:count أرفف',
     'shelves_long' => 'أرفف الكتب',
-    'shelves_empty' => 'لم يتم إنشاء أي أرفف',
+    'shelves_empty' => 'لم ينشأ أي رف',
     'shelves_create' => 'إنشاء رف جديد',
-    'shelves_popular' => 'أرÙ\81Ù\81 Ø´Ø¹Ø¨Ù\8aة',
+    'shelves_popular' => 'أرÙ\81Ù\81 Ø±Ø§Ø¦Ø¬ة',
     'shelves_new' => 'أرفف جديدة',
     'shelves_new_action' => 'رف جديد',
     'shelves_popular_empty' => 'ستظهر هنا الأرفف الأكثر رواجًا.',
-    'shelves_new_empty' => 'ستظÙ\87ر Ù\87Ù\86ا Ø§Ù\84أرÙ\81Ù\81 Ø§Ù\84تÙ\8a ØªÙ\85 Ø¥Ù\86شاؤÙ\87ا مؤخرًا.',
+    'shelves_new_empty' => 'ستظÙ\87ر Ù\87Ù\86ا Ø§Ù\84أرÙ\81Ù\81 Ø§Ù\84تÙ\8a Ø£Ù\86شئت مؤخرًا.',
     'shelves_save' => 'حفظ الرف',
     'shelves_books' => 'كتب على هذا الرف',
     'shelves_add_books' => 'إضافة كتب لهذا الرف',
-    'shelves_drag_books' => 'اسحب Ø§Ù\84Ù\83تب Ù\87Ù\86ا Ù\84إضاÙ\81تÙ\87ا Ù\84هذا الرف',
+    'shelves_drag_books' => 'اسحب Ø§Ù\84Ù\83تب Ù\87Ù\86ا Ù\84إضاÙ\81تÙ\87ا Ù\81Ù\8a هذا الرف',
     'shelves_empty_contents' => 'لا توجد كتب مخصصة لهذا الرف',
     'shelves_edit_and_assign' => 'تحرير الرف لإدراج كتب',
-    'shelves_edit_named' => 'تحرير رف الكتب: الاسم',
+    'shelves_edit_named' => 'تحرير رف الكتب :name',
     'shelves_edit' => 'تحرير رف الكتب',
     'shelves_delete' => 'حذف رف الكتب',
-    'shelves_delete_named' => 'حذف رف الكتب: الاسم',
-    'shelves_delete_explain' => "سيؤدي هذا إلى حذف رف الكتب مع الاسم ':المُسمى به'. لن يتم حذف الكتب المتضمنة.",
+    'shelves_delete_named' => 'حذف رف الكتب :name',
+    'shelves_delete_explain' => "سيؤدي هذا إلى حذف رف الكتب المسمى ':name'، ولن تحذف الكتب المتضمنة فيه.",
     'shelves_delete_confirmation' => 'هل أنت متأكد من أنك تريد حذف هذا الرف؟',
     'shelves_permissions' => 'أذونات رف الكتب',
     'shelves_permissions_updated' => 'تم تحديث أذونات رف الكتب',
     'shelves_permissions_active' => 'أذونات رف الكتب نشطة',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'نسخ أذونات الوصول إلى الكتب',
     'shelves_copy_permissions' => 'نسخ الأذونات',
     'shelves_copy_permissions_explain' => 'سيؤدي هذا إلى تطبيق إعدادات الأذونات الحالية لهذا الرف على جميع الكتب المتضمنة فيه. قبل التفعيل، تأكد من حفظ أي تغييرات في أذونات هذا الرف.',
-    'shelves_copy_permission_success' => 'تم نسخ أذونات رف الكتب إلى: عد الكتب',
+    'shelves_copy_permission_success' => 'تم نسخ أذونات رف الكتب إلى :count books',
 
     // Books
     'book' => 'كتاب',
@@ -115,7 +119,7 @@ return [
     'books_create' => 'إنشاء كتاب جديد',
     'books_delete' => 'حذف الكتاب',
     'books_delete_named' => 'حذف كتاب :bookName',
-    'books_delete_explain' => 'سيتم حذف كتاب \':bookName\'. ستتم إزالة جميع الفصول والصفحات.',
+    'books_delete_explain' => 'سيتم حذف كتاب \':bookName\'، وأيضا حذف جميع الفصول والصفحات.',
     'books_delete_confirmation' => 'تأكيد حذف الكتاب؟',
     'books_edit' => 'تعديل الكتاب',
     'books_edit_named' => 'تعديل كتاب :bookName',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'الفصول الأخيرة',
     'books_sort_show_other' => 'عرض كتب أخرى',
     'books_sort_save' => 'حفظ الترتيب الجديد',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'فصل',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'نقل الفصل',
     'chapters_move_named' => 'نقل فصل :chapterName',
     'chapter_move_success' => 'تم نقل الفصل إلى :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'أذونات الفصل',
     'chapters_empty' => 'لا توجد أي صفحات في هذا الفصل حالياً',
     'chapters_permissions_active' => 'أذونات الفصل مفعلة',
@@ -215,7 +223,7 @@ return [
     'pages_revisions_created_by' => 'أنشئ بواسطة',
     'pages_revisions_date' => 'تاريخ المراجعة',
     'pages_revisions_number' => '#',
-    'pages_revisions_numbered' => 'مراجعة #: رقم تعريفي',
+    'pages_revisions_numbered' => 'مراجعة #:id',
     'pages_revisions_numbered_changes' => 'مراجعة #: رقم تعريفي التغييرات',
     'pages_revisions_changelog' => 'سجل التعديل',
     'pages_revisions_changes' => 'التعديلات',
@@ -228,8 +236,9 @@ return [
     'pages_permissions_active' => 'أذونات الصفحة مفعلة',
     'pages_initial_revision' => 'نشر مبدئي',
     'pages_initial_name' => 'صفحة جديدة',
-    'pages_editing_draft_notification' => 'جار تعديل مسودة لم يتم حفظها من :timeDiff.',
+    'pages_editing_draft_notification' => 'جارٍ تعديل مسودة لم يتم حفظها من :timeDiff.',
     'pages_draft_edited_notification' => 'تم تحديث هذه الصفحة منذ ذلك الوقت. من الأفضل التخلص من هذه المسودة.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count من المستخدمين بدأوا بتعديل هذه الصفحة',
         'start_b' => ':userName بدأ بتعديل هذه الصفحة',
@@ -237,7 +246,7 @@ return [
         'time_b' => 'في آخر :minCount دقيقة/دقائق',
         'message' => 'وقت البدء: احرص على عدم الكتابة فوق تحديثات بعضنا البعض!',
     ],
-    'pages_draft_discarded' => 'تم التخلص من المسودة. تم تحديث المحرر بمحتوى الصفحة الحالي',
+    'pages_draft_discarded' => 'تم التخلص من المسودة وتحديث المحرر بمحتوى الصفحة الحالي',
     'pages_specific' => 'صفحة محددة',
     'pages_is_template' => 'قالب الصفحة',
 
@@ -253,16 +262,26 @@ return [
     'tags_explain' => "إضافة الوسوم تساعد بترتيب وتقسيم المحتوى. \n من الممكن وضع قيمة لكل وسم لترتيب أفضل وأدق.",
     'tags_add' => 'إضافة وسم آخر',
     'tags_remove' => 'إزالة هذه العلامة',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'المرفقات',
     'attachments_explain' => 'ارفع بعض الملفات أو أرفق بعض الروابط لعرضها بصفحتك. ستكون الملفات والروابط معروضة في الشريط الجانبي للصفحة.',
-    'attachments_explain_instant_save' => 'سÙ\8aتÙ\85 Ø­Ù\81ظ Ø§Ù\84تغÙ\8aÙ\8aرات Ù\87Ù\86ا Ø¨Ù\84حظتÙ\87ا',
+    'attachments_explain_instant_save' => 'سÙ\8aتÙ\85 Ø­Ù\81ظ Ø§Ù\84تغÙ\8aÙ\8aرات Ù\87Ù\86ا Ø¢Ù\86Ù\8aا.',
     'attachments_items' => 'العناصر المرفقة',
     'attachments_upload' => 'رفع ملف',
     'attachments_link' => 'إرفاق رابط',
     'attachments_set_link' => 'تحديد الرابط',
     'attachments_delete' => 'هل أنت متأكد من أنك تريد حذف هذا المرفق؟',
     'attachments_dropzone' => 'أسقط الملفات أو اضغط هنا لإرفاق ملف',
-    'attachments_no_files' => 'لم يتم رفع أي ملفات',
+    'attachments_no_files' => 'لم تُرفع أي ملفات',
     'attachments_explain_link' => 'بالإمكان إرفاق رابط في حال عدم تفضيل رفع ملف. قد يكون الرابط لصفحة أخرى أو لملف في أحد خدمات التخزين السحابي.',
     'attachments_link_name' => 'اسم الرابط',
     'attachment_link' => 'رابط المرفق',
@@ -287,7 +306,7 @@ return [
     'templates_prepend_content' => 'بادئة محتوى الصفحة',
 
     // Profile View
-    'profile_user_for_x' => 'المستخدم لـ : الوقت',
+    'profile_user_for_x' => 'المستخدم لـ :time',
     'profile_created_content' => 'المحتوى المنشأ',
     'profile_not_created_pages' => 'لم يتم إنشاء أي صفحات بواسطة :userName',
     'profile_not_created_chapters' => 'لم يتم إنشاء أي فصول بواسطة :userName',
@@ -299,7 +318,7 @@ return [
     'comments' => 'تعليقات',
     'comment_add' => 'إضافة تعليق',
     'comment_placeholder' => 'ضع تعليقاً هنا',
-    'comment_count' => '{0} ا توجد تعليقات|{1} تعليق واحد|{2} تعليقان|[3,*] :count تعليقات',
+    'comment_count' => '{0} لا توجد تعليقات|{1} تعليق واحد|{2} تعليقان[3,*] :count تعليقات',
     'comment_save' => 'حفظ التعليق',
     'comment_saving' => 'جار حفظ التعليق...',
     'comment_deleting' => 'جار حذف التعليق...',
@@ -313,8 +332,16 @@ return [
     'comment_in_reply_to' => 'رداً على :commentId',
 
     // Revision
-    'revision_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف هذا الإصدار؟',
-    'revision_restore_confirm' => 'هل أنت متأكد من أنك تريد استعادة هذا الإصدار؟ سيتم استبدال محتوى الصفحة الحالية.',
-    'revision_delete_success' => 'تم حذف الإصدار',
-    'revision_cannot_delete_latest' => 'لايمكن حذف آخر إصدار.'
+    'revision_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف هذه المراجعة؟',
+    'revision_restore_confirm' => 'هل أنت متأكد من أنك تريد استعادة هذه المراجعة؟ سيتم استبدال محتوى الصفحة الحالية.',
+    'revision_delete_success' => 'تم حذف المراجعة',
+    'revision_cannot_delete_latest' => 'لايمكن حذف آخر مراجعة.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 93e8201ab7599e3a11a40fb44d076461d07137b8..c9851588b3d345a06485c8f9aa44a876a22e9142 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'تعذر العثور على عنوان بريد إلكتروني، لهذا المستخدم، في البيانات المقدمة من نظام المصادقة الخارجي',
     'saml_invalid_response_id' => 'لم يتم التعرف على الطلب من نظام التوثيق الخارجي من خلال عملية تبدأ بهذا التطبيق. العودة بعد تسجيل الدخول يمكن أن يسبب هذه المشكلة.',
     'saml_fail_authed' => 'تسجيل الدخول باستخدام :system فشل، النظام لم يوفر التفويض الناجح',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'لم يتم تعريف أي إجراء',
     'social_login_bad_response' => "حصل خطأ خلال تسجيل الدخول باستخدام :socialAccount \n:error",
     'social_account_in_use' => 'حساب :socialAccount قيد الاستخدام حالياً, الرجاء محاولة الدخول باستخدام خيار :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'لم يتم العثور على الصفحة',
     'sorry_page_not_found' => 'عفواً, لا يمكن العثور على الصفحة التي تبحث عنها.',
     'sorry_page_not_found_permission_warning' => 'إذا كنت تتوقع أن تكون هذه الصفحة موجودة، قد لا يكون لديك تصريح بمشاهدتها.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'العودة للصفحة الرئيسية',
     'error_occurred' => 'حدث خطأ',
     'app_down' => ':appName لا يعمل حالياً',
index 20789d842eacbe545e17572081a120542aa8127d..c72222386fe81b28f7e10dc9e5540207bc176643 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'الصيانة',
     'maint_image_cleanup' => 'تنظيف الصور',
-    'maint_image_cleanup_desc' => "مسح الصفحة ومراجعة المحتوى للتحقق من أي الصور والرسوم المستخدمة حاليًا وأي الصور زائدة عن الحاجة. تأكد من إنشاء قاعدة بيانات كاملة و نسخة احتياطية للصور قبل تشغيل هذا.",
+    'maint_image_cleanup_desc' => 'مسح الصفحة ومراجعة المحتوى للتحقق من أي الصور والرسوم المستخدمة حاليًا وأي الصور زائدة عن الحاجة. تأكد من إنشاء قاعدة بيانات كاملة و نسخة احتياطية للصور قبل تشغيل هذا.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => 'بدء التنظيف',
     'maint_image_cleanup_warning' => 'يوجد عدد :count من الصور المحتمل عدم استخدامها. تأكيد حذف الصور؟',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'سلة المحذوفات',
     'recycle_bin_desc' => 'هنا يمكنك استعادة العناصر التي تم حذفها أو اختيار إزالتها نهائيا من النظام. هذه القائمة غير مصفاة خلافاً لقوائم الأنشطة المماثلة في النظام حيث يتم تطبيق عوامل تصفية الأذونات.',
     'recycle_bin_deleted_item' => 'عنصر محذوف',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'حُذف بواسطة',
     'recycle_bin_deleted_at' => 'وقت الحذف',
     'recycle_bin_permanently_delete' => 'حُذف نهائيًا',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'العناصر المراد استرجاعها',
     'recycle_bin_restore_confirm' => 'سيعيد هذا الإجراء العنصر المحذوف ، بما في ذلك أي عناصر فرعية ، إلى موقعه الأصلي. إذا تم حذف الموقع الأصلي منذ ذلك الحين ، وهو الآن في سلة المحذوفات ، فسيلزم أيضًا استعادة العنصر الأصلي.',
     'recycle_bin_restore_deleted_parent' => 'تم حذف أصل هذا العنصر أيضًا. سيبقى حذفه حتى يتم استعادة ذلك الأصل أيضًا.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'المحذوف: قُم بعد إجمالي العناصر من سلة المحذوفات.',
     'recycle_bin_restore_notification' => 'المرتجع: قُم بعد إجمالي العناصر من سلة المحذوفات.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'المستخدم',
     'audit_table_event' => 'الحدث',
     'audit_table_related' => 'العنصر أو التفاصيل ذات الصلة',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'تاريخ النشاط',
     'audit_date_from' => 'نطاق التاريخ من',
     'audit_date_to' => 'نطاق التاريخ إلى',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'تفاصيل الدور',
     'role_name' => 'اسم الدور',
     'role_desc' => 'وصف مختصر للدور',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'ربط الحساب بمواقع التواصل',
     'role_system' => 'أذونات النظام',
     'role_manage_users' => 'إدارة المستخدمين',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'إدارة قوالب الصفحة',
     'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API',
     'role_manage_settings' => 'إدارة إعدادات التطبيق',
+    'role_export_content' => 'Export content',
     'role_asset' => 'أذونات الأصول',
     'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.',
     'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'أدوار المستخدمين',
     'users_role_desc' => 'حدد الأدوار التي سيتم تعيين هذا المستخدم لها. إذا تم تعيين مستخدم لأدوار متعددة ، فسيتم تكديس الأذونات من هذه الأدوار وسيتلقى كل قدرات الأدوار المعينة.',
     'users_password' => 'كلمة مرور المستخدم',
-    'users_password_desc' => 'قم بتعيين كلمة مرور مستخدمة لتسجيل الدخول إلى التطبيق. يجب ألا يقل طول هذه الكلمة عن 6 أحرف.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'يمكنك اختيار إرسال دعوة بالبريد الإلكتروني إلى هذا المستخدم مما يسمح له بتعيين كلمة المرور الخاصة به أو يمكنك تعيين كلمة المرور الخاصة به بنفسك.',
     'users_send_invite_option' => 'أرسل بريدًا إلكترونيًا لدعوة المستخدم',
     'users_external_auth_id' => 'ربط الحساب بمواقع التواصل',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'قم بإنشاء رمز مميز',
     'users_api_tokens_expires' => 'انتهاء مدة الصلاحية',
     'users_api_tokens_docs' => 'وثائق API',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'قم بإنشاء رمز API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف رمز API؟',
     'user_api_token_delete_success' => 'تم حذف رمز الـ API بنجاح',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 3c03f2efd7269e68f72e8e5f96cc921b828abd30..d4d3aaf26b1f2f4a658c10ea1de911c405549dca 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'يجب أن يقتصر :attribute على حروف أو أرقام أو شرطات فقط.',
     'alpha_num'            => 'يجب أن يقتصر :attribute على الحروف والأرقام فقط.',
     'array'                => 'يجب أن تكون السمة مصفوفة.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => 'يجب أن يكون التاريخ :attribute قبل :date.',
     'between'              => [
         'numeric' => 'يجب أن يكون :attribute بين :min و :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'يجب أن تكون السمة: سلسلة.',
     'timezone'             => 'يجب أن تكون :attribute منطقة صالحة.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => 'تم حجز :attribute من قبل.',
     'url'                  => 'صيغة :attribute غير صالحة.',
     'uploaded'             => 'تعذر تحميل الملف. قد لا يقبل الخادم ملفات بهذا الحجم.',
index a495733549313f8ece45e61a9b29fd97c6983451..29e8bcafed23a6a522670aa7e599722d2e68cb66 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'създадена страница',
-    'page_create_notification'    => 'Страницата беше успешно създадена',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'обновена страница',
-    'page_update_notification'    => 'Страницата успешно обновена',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'изтрита страница',
-    'page_delete_notification'    => 'Страницата беше успешно изтрита',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'възстановена страница',
-    'page_restore_notification'   => 'Страницата беше успешно възстановена',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'преместена страница',
 
     // Chapters
     'chapter_create'              => 'създадена страница',
-    'chapter_create_notification' => 'Главата беше успешно създадена',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'обновена глава',
-    'chapter_update_notification' => 'Главата беше успешно обновена',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'изтрита глава',
-    'chapter_delete_notification' => 'Главата беше успешно изтрита',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'преместена глава',
 
     // Books
     'book_create'                 => 'създадена книга',
-    'book_create_notification'    => 'Книгата беше успешно създадена',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'обновена книга',
-    'book_update_notification'    => 'Книгата беше успешно обновена',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'изтрита книга',
-    'book_delete_notification'    => 'Книгата беше успешно изтрита',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'сортирана книга',
-    'book_sort_notification'      => 'Книгата беше успешно преподредена',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'създаден рафт',
-    'bookshelf_create_notification'    => 'Рафтът беше успешно създаден',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'обновен рафт',
-    'bookshelf_update_notification'    => 'Рафтът беше успешно обновен',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'изтрит рафт',
-    'bookshelf_delete_notification'    => 'Рафтът беше успешно изтрит',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'коментирано на',
index 018e871e108ddd821fb882e22d52ac2b42bad48d..42c3714684aa47d1ae8ddddcf739b86f08b57a53 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Имейл',
     'password' => 'Парола',
     'password_confirm' => 'Потвърди паролата',
-    'password_hint' => 'Трябва да бъде поне 7 символа',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Забравена парола?',
     'remember_me' => 'Запомни ме',
     'ldap_email_hint' => 'Моля въведете емейл, който да използвате за дадения акаунт.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Този емейл домейн към момента няма достъп до приложението',
     'register_success' => 'Благодарим Ви за регистрацията! В момента сте регистриран и сте вписани в приложението.',
 
-
     // Password Reset
     'reset_password' => 'Нулиране на паролата',
     'reset_password_send_instructions' => 'Въведете емейла си и ще ви бъде изпратен емейл с линк за нулиране на паролата.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Вие получихте този емейл, защото поискахте вашата парола да бъде занулена.',
     'email_reset_not_requested' => 'Ако Вие не сте поискали зануляването на паролата, няма нужда от други действия.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Потвърди емейла си за :appName',
     'email_confirm_greeting' => 'Благодарим Ви, че се присъединихте към :appName!',
     'email_confirm_text' => 'Моля, потвърдете вашия имейл адрес, като следвате връзката по-долу:',
     'email_confirm_action' => 'Потвърдете имейл',
     'email_confirm_send_error' => 'Нужно ви е потвърждение чрез емейл, но системата не успя да го изпрати. Моля свържете се с администратора, за да проверите дали вашият емейл адрес е конфигуриран правилно.',
-    'email_confirm_success' => 'Адресът на електронната ви поща е потвърден!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Беше изпратен имейл с потвърждение, Моля, проверете кутията си.',
 
     'email_not_confirmed' => 'Имейл адресът не е потвърден',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Добре дошли в :appName!',
     'user_invite_page_text' => 'За да финализирате вашият акаунт и да получите достъп трябва да определите парола, която да бъде използвана за следващия влизания в :appName.',
     'user_invite_page_confirm_button' => 'Потвърди паролата',
-    'user_invite_success' => 'Паролата е потвърдена и вече имате достъп до :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index f3a15563cd4200f328dcf356734081eab6e97aab..f5ff09e7ccb1cc45b9998509f64d5bacf9e381df 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Роля',
     'cover_image' => 'Основно изображение',
     'cover_image_description' => 'Картината трябва да е приблизително 440х250 пиксела.',
-    
+
     // Actions
     'actions' => 'Действия',
     'view' => 'Преглед',
@@ -33,13 +33,20 @@ return [
     'copy' => 'Копирай',
     'reply' => 'Отговори',
     'delete' => 'Изтрий',
-    'delete_confirm' => 'Confirm Deletion',
+    'delete_confirm' => 'Потвърдете изтриването',
     'search' => 'Търси',
     'search_clear' => 'Изчисти търсенето',
     'reset' => 'Нулирай',
     'remove' => 'Премахване',
     'add' => 'Добави',
+    'configure' => 'Configure',
     'fullscreen' => 'Пълен екран',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Опции за сортиране',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Няма активност за показване',
     'no_items' => 'Няма налични артикули',
     'back_to_top' => 'Върнете се в началото',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Активирай детайли',
     'toggle_thumbnails' => 'Активирай миниатюри',
     'details' => 'Подробности',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Изглед списък',
     'default' => 'Основен',
     'breadcrumb' => 'Трасиране',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Лични данни',
+    'terms_of_service' => 'Общи условия',
 ];
index b15aa6f796fb6cad2390933917c6289329695700..4be89bfb5a9ed2e8543e6c764a33c92621080f0a 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Зареди повече',
     'image_image_name' => 'Име на изображението',
     'image_delete_used' => 'Това изображение е използвано в страницата по-долу.',
-    'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
+    'image_delete_confirm_text' => 'Сигурни ли сте, че искате да изтриете това изображение?',
     'image_select_image' => 'Изберете изображение',
     'image_dropzone' => 'Поставете тук изображение или кликнете тук за да качите',
     'images_deleted' => 'Изображението е изтрито',
index 2e35b5afa42fc8a5f617c56ce228ce48235c1230..ae09a82d7facac25dcfe1d38b00d652907278106 100644 (file)
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => 'Създадено преди :timeLength от :user',
     'meta_updated' => 'Актуализирано :timeLength',
     'meta_updated_name' => 'Актуализирано преди :timeLength от :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Притежавано от :user',
     'entity_select' => 'Избор на обект',
     'images' => 'Изображения',
     'my_recent_drafts' => 'Моите скорошни драфтове',
     'my_recently_viewed' => 'Моите скорошни преглеждания',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'Моите фаворити',
     'no_pages_viewed' => 'Не сте прегледали никакви страници',
     'no_pages_recently_created' => 'Не са били създавани страници скоро',
     'no_pages_recently_updated' => 'Не са били актуализирани страници скоро',
@@ -34,13 +36,14 @@ return [
     'export_html' => 'Прикачени уеб файлове',
     'export_pdf' => 'PDF файл',
     'export_text' => 'Обикновен текстов файл',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Права',
     'permissions_intro' => 'Веднъж добавени, тези права ще вземат приоритет над всички други установени права.',
     'permissions_enable' => 'Разреши уникални права',
     'permissions_save' => 'Запази права',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Собственик',
 
     // Search
     'search_results' => 'Резултати от търсенето',
@@ -49,7 +52,7 @@ return [
     'search_no_pages' => 'Няма страници отговарящи на търсенето',
     'search_for_term' => 'Търси :term',
     'search_more' => 'Още резултати',
-    'search_advanced' => 'Advanced Search',
+    'search_advanced' => 'Подробно търсене',
     'search_terms' => 'Search Terms',
     'search_content_type' => 'Тип на съдържание',
     'search_exact_matches' => 'Точни съвпадения',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Задаване на права',
     'search_created_by_me' => 'Създадено от мен',
     'search_updated_by_me' => 'Обновено от мен',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Притежаван от мен',
     'search_date_options' => 'Настройки на дати',
     'search_updated_before' => 'Обновено преди',
     'search_updated_after' => 'Обновено след',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Настройки за достъп до рафта с книги',
     'shelves_permissions_updated' => 'Настройките за достъп до рафта с книги е обновен',
     'shelves_permissions_active' => 'Настройките за достъп до рафта с книги е активен',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Копирай настойките за достъп към книгите',
     'shelves_copy_permissions' => 'Копирай настройките за достъп',
     'shelves_copy_permissions_explain' => 'Това ще приложи настоящите настройки за достъп на този рафт с книги за всички книги, съдържащи се в него. Преди да активирате, уверете се, че всички промени в настройките за достъп на този рафт са запазени.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Последна глава',
     'books_sort_show_other' => 'Покажи други книги',
     'books_sort_save' => 'Запази новата подредба',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Глава',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Премести глава',
     'chapters_move_named' => 'Премести глава :chapterName',
     'chapter_move_success' => 'Главата беше преместена в :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Настойки за достъп на главата',
     'chapters_empty' => 'Няма създадени страници в тази глава.',
     'chapters_permissions_active' => 'Настройките за достъп до глава са активни',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Нова страница',
     'pages_editing_draft_notification' => 'В момента редактирате чернова, която беше последно обновена :timeDiff.',
     'pages_draft_edited_notification' => 'Тази страница беше актуализирана от тогава. Препоръчително е да изтриете настоящата чернова.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count потребителя започнаха да редактират настоящата страница',
         'start_b' => ':userName в момента редактира тази страница',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Добавете няколко тага за да категоризирате по добре вашето съдържание. \n Може да добавите съдържание на таговете за по-подробна организация.",
     'tags_add' => 'Добави друг таг',
     'tags_remove' => 'Премахни този таг',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Прикачени файлове',
     'attachments_explain' => 'Прикачете файлове или линкове, които да са видими на вашата страница. Същите ще бъдат видими във вашето странично поле.',
     'attachments_explain_instant_save' => 'Промените тук се запазват веднага.',
@@ -260,7 +279,7 @@ return [
     'attachments_upload' => 'Прикачен файл',
     'attachments_link' => 'Прикачване на линк',
     'attachments_set_link' => 'Поставяне на линк',
-    'attachments_delete' => 'Are you sure you want to delete this attachment?',
+    'attachments_delete' => 'Сигурни ли сте, че искате да изтриете прикачения файл?',
     'attachments_dropzone' => 'Поставете файлове или цъкнете тук за да прикачите файл',
     'attachments_no_files' => 'Няма прикачени фалове',
     'attachments_explain_link' => 'Може да прикачите линк, ако не искате да качвате файл. Този линк може да бъде към друга страница или към файл в облакова пространство.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Наистина ли искате да изтриете тази версия?',
     'revision_restore_confirm' => 'Сигурни ли сте, че искате да изтриете тази версия? Настоящата страница ще бъде заместена.',
     'revision_delete_success' => 'Версията беше изтрита',
-    'revision_cannot_delete_latest' => 'Не може да изтриете последната версия.'
+    'revision_cannot_delete_latest' => 'Не може да изтриете последната версия.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 4b062af06b557237fe37481dd0e6b004afb0f27b..514b274ec6da2296bbf0fed93df82ded75707bdc 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Не успяхме да намерим емейл адрес, за този потребител, от информацията предоставена от външната система',
     'saml_invalid_response_id' => 'Заявката от външната система не е разпознат от процеса започнат от това приложение. Връщането назад след влизане може да породи този проблем.',
     'saml_fail_authed' => 'Влизането чрез :system не беше успешно, системата не успя да оторизира потребителя',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Действието не беше дефинирано',
     'social_login_bad_response' => "Възникна грешка по време на :socialAccount login: \n:error",
     'social_account_in_use' => 'Този :socialAccount вече е използван. Опитайте се да влезете чрез опцията за :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Страницата не е намерена',
     'sorry_page_not_found' => 'Страницата, която търсите не може да бъде намерена.',
     'sorry_page_not_found_permission_warning' => 'Ако смятате, че тази страница съществува, най-вероятно нямате право да я преглеждате.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Назад към Начало',
     'error_occurred' => 'Възникна грешка',
     'app_down' => ':appName не е достъпно в момента',
index 5fb24056e05fd5011f09afe640a019df530cf019..51c651ae703f09f67c84f2ffb5099e09a6f0d911 100644 (file)
@@ -35,13 +35,13 @@ return [
     'app_primary_color' => 'Основен цвят на приложението',
     'app_primary_color_desc' => 'Изберете основния цвят на приложението, включително на банера, бутоните и линковете.',
     'app_homepage' => 'Application Homepage',
-    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_select' => 'Select a page',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
+    'app_homepage_desc' => 'Изберете начална страница, която ще замени изгледа по подразбиране. Дефинираните права на страницата, която е избрана ще бъдат игнорирани.',
+    'app_homepage_select' => 'Избери страница',
+    'app_footer_links' => 'Футър линкове',
+    'app_footer_links_desc' => 'Добави линк в съдържанието на футъра. Добавените линкове ще се показват долу в повечето страници, включително и в страниците, в които логването не е задължително. Можете да използвате заместител "trans::<key>", за да използвате дума дефинирана от системата. Пример: Използването на "trans::common.privacy_policy" ще покаже "Лични данни" или на "trans::common.terms_of_service" ще покаже "Общи условия".',
     'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_footer_links_url' => 'Линк URL',
+    'app_footer_links_add' => 'Добави футър линк',
     'app_disable_comments' => 'Disable Comments',
     'app_disable_comments_toggle' => 'Disable comments',
     'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Maintenance',
     'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => 'Run Cleanup',
     'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
@@ -80,20 +80,21 @@ return [
     'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!',
     'maint_send_test_email' => 'Send a Test Email',
     'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.',
-    'maint_send_test_email_run' => 'Send test email',
-    'maint_send_test_email_success' => 'Email sent to :address',
-    'maint_send_test_email_mail_subject' => 'Test Email',
-    'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
-    'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
-    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
-    'maint_recycle_bin_open' => 'Open Recycle Bin',
+    'maint_send_test_email_run' => 'Изпрати тестов имейл',
+    'maint_send_test_email_success' => 'Имейл изпратен на :address',
+    'maint_send_test_email_mail_subject' => 'Тестов Имейл',
+    'maint_send_test_email_mail_greeting' => 'Изпращането на Имейл работи!',
+    'maint_send_test_email_mail_text' => 'Поздравления! След като получихте този имейл, Вашите имейл настройки са конфигурирани правилно.',
+    'maint_recycle_bin_desc' => 'Изтрити рафти, книги, глави и страници се преместват в кошчето, откъдето можете да ги възстановите или изтриете завинаги. Стари съдържания в кошчето ще бъдат изтрити автоматично след време, в зависимост от настройките на системата.',
+    'maint_recycle_bin_open' => 'Отвори Кошчето',
 
     // Recycle Bin
-    'recycle_bin' => 'Recycle Bin',
+    'recycle_bin' => 'Кошче',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
-    'recycle_bin_deleted_by' => 'Deleted By',
-    'recycle_bin_deleted_at' => 'Deletion Time',
+    'recycle_bin_deleted_item' => 'Изтрит предмет',
+    'recycle_bin_deleted_parent' => 'Parent',
+    'recycle_bin_deleted_by' => 'Изтрит от',
+    'recycle_bin_deleted_at' => 'Час на изтриване',
     'recycle_bin_permanently_delete' => 'Permanently Delete',
     'recycle_bin_restore' => 'Restore',
     'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items to be Restored',
     'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -111,31 +113,33 @@ return [
     'audit' => 'Audit Log',
     'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
     'audit_event_filter' => 'Event Filter',
-    'audit_event_filter_no_filter' => 'No Filter',
-    'audit_deleted_item' => 'Deleted Item',
-    'audit_deleted_item_name' => 'Name: :name',
-    'audit_table_user' => 'User',
-    'audit_table_event' => 'Event',
+    'audit_event_filter_no_filter' => 'Без филтър',
+    'audit_deleted_item' => 'Изтрит предмет',
+    'audit_deleted_item_name' => 'Име: :name',
+    'audit_table_user' => 'Потребител',
+    'audit_table_event' => 'Събитие',
     'audit_table_related' => 'Related Item or Detail',
-    'audit_table_date' => 'Activity Date',
-    'audit_date_from' => 'Date Range From',
-    'audit_date_to' => 'Date Range To',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Дата на активност',
+    'audit_date_from' => 'Време от',
+    'audit_date_to' => 'Време до',
 
     // Role Settings
-    'roles' => 'Roles',
-    'role_user_roles' => 'User Roles',
-    'role_create' => 'Create New Role',
-    'role_create_success' => 'Role successfully created',
-    'role_delete' => 'Delete Role',
-    'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.',
-    'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.',
-    'role_delete_no_migration' => "Don't migrate users",
-    'role_delete_sure' => 'Are you sure you want to delete this role?',
-    'role_delete_success' => 'Role successfully deleted',
+    'roles' => 'Роли',
+    'role_user_roles' => 'Потребителски роли',
+    'role_create' => 'Създай нова роля',
+    'role_create_success' => 'Ролята беше успешно създадена',
+    'role_delete' => 'Изтрий роля',
+    'role_delete_confirm' => 'Това ще изтрие ролята \':roleName\'.',
+    'role_delete_users_assigned' => 'В тази роля се намират :userCount потребители. Ако искате да преместите тези потребители в друга роля, моля изберете нова роля отдолу.',
+    'role_delete_no_migration' => "Не премествай потребителите в нова роля",
+    'role_delete_sure' => 'Сигурни ли сте, че искате да изтриете тази роля?',
+    'role_delete_success' => 'Ролята беше успешно изтрита',
     'role_edit' => 'Редактиране на роля',
     'role_details' => 'Детайли на роля',
     'role_name' => 'Име на ролята',
     'role_desc' => 'Кратко описание на ролята',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Външни ауторизиращи ID-a',
     'role_system' => 'Настойки за достъп на системата',
     'role_manage_users' => 'Управление на потребители',
@@ -145,44 +149,45 @@ return [
     'role_manage_page_templates' => 'Управление на шаблони на страници',
     'role_access_api' => 'Достъп до API на системата',
     'role_manage_settings' => 'Управление на настройките на приложението',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Настройки за достъп до активи',
-    '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' => 'Тези настойки за достъп контролират достъпа по подразбиране до активите в системата. Настойките за достъп до книги, глави и страници ще отменят тези настройки.',
+    'roles_system_warning' => 'Важно: Добавянето на потребител в някое от горните три роли може да му позволи да промени собствените си права или правата на другите в системата. Възлагайте тези роли само на доверени потребители.',
+    'role_asset_desc' => 'Тези настройки за достъп контролират достъпа по подразбиране до активите в системата. Настройките за достъп до книги, глави и страници ще отменят тези настройки.',
     'role_asset_admins' => 'Администраторите автоматично получават достъп до цялото съдържание, но тези опции могат да показват или скриват опциите за потребителския интерфейс.',
     'role_all' => 'Всички',
     'role_own' => 'Собствени',
     'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
-    'role_save' => 'Save Role',
-    'role_update_success' => 'Role successfully updated',
-    'role_users' => 'Users in this role',
-    'role_users_none' => 'No users are currently assigned to this role',
+    'role_save' => 'Запази ролята',
+    'role_update_success' => 'Ролята беше успешно актуализирана',
+    'role_users' => 'Потребители в тази роля',
+    'role_users_none' => 'В момента няма потребители, назначени за тази роля',
 
     // Users
-    'users' => 'Users',
-    'user_profile' => 'User Profile',
-    'users_add_new' => 'Add New User',
-    'users_search' => 'Search Users',
-    'users_latest_activity' => 'Latest Activity',
-    'users_details' => 'User Details',
+    'users' => 'Потребители',
+    'user_profile' => 'Потребителски профил',
+    'users_add_new' => 'Добави нов потребител',
+    'users_search' => 'Търси Потребители',
+    'users_latest_activity' => 'Последна активност',
+    'users_details' => 'Потребителски детайли',
     'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
     'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
     'users_role' => 'User Roles',
     'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
     'users_password' => 'User Password',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
     'users_send_invite_option' => 'Send user invite email',
     'users_external_auth_id' => 'External Authentication ID',
     'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
     'users_password_warning' => 'Only fill the below if you would like to change your password.',
     'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.',
-    'users_delete' => 'Delete User',
-    'users_delete_named' => 'Delete user :userName',
-    'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.',
-    'users_delete_confirm' => 'Are you sure you want to delete this user?',
-    'users_migrate_ownership' => 'Migrate Ownership',
+    'users_delete' => 'Изтрий потребител',
+    'users_delete_named' => 'Изтрий потребителя :userName',
+    'users_delete_warning' => 'Това изцяло ще изтрие този потребител с името \':userName\' от системата.',
+    'users_delete_confirm' => 'Сигурни ли сте, че искате да изтриете този потребител?',
+    'users_migrate_ownership' => 'Мигрирайте собствеността на сайта',
     'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
-    'users_none_selected' => 'No user selected',
+    'users_none_selected' => 'Няма избрани потребители',
     'users_delete_success' => 'User successfully removed',
     'users_edit' => 'Edit User',
     'users_edit_profile' => 'Edit Profile',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Create Token',
     'users_api_tokens_expires' => 'Expires',
     'users_api_tokens_docs' => 'API Documentation',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Create API Token',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
     'user_api_token_delete_success' => 'API token successfully deleted',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 4031de2ae743b75bafd49195ff06b29a347cbf22..0a5b81d92a99deccc961e69658a7b9da10c7dab7 100644 (file)
@@ -8,67 +8,68 @@
 return [
 
     // Standard laravel validation lines
-    'accepted'             => 'The :attribute must be accepted.',
-    'active_url'           => 'The :attribute is not a valid URL.',
-    'after'                => 'The :attribute must be a date after :date.',
-    '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.',
-    'before'               => 'The :attribute must be a date before :date.',
+    'accepted'             => ':attribute трябва да бъде одобрен.',
+    'active_url'           => ':attribute не е валиден URL адрес.',
+    'after'                => ':attribute трябва да е дата след :date.',
+    'alpha'                => ':attribute може да съдържа само букви.',
+    'alpha_dash'           => ':attribute може да съдържа само букви, числа, тире и долна черта.',
+    'alpha_num'            => ':attribute може да съдържа само букви и числа.',
+    'array'                => ':attribute трябва да е масив (array).',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'before'               => ':attribute трябва да е дата след :date.',
     'between'              => [
-        'numeric' => 'The :attribute must be between :min and :max.',
-        'file'    => 'The :attribute must be between :min and :max kilobytes.',
-        'string'  => 'The :attribute must be between :min and :max characters.',
+        'numeric' => ':attribute трябва да е между :min и :max.',
+        'file'    => ':attribute трябва да е между :min и :max килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде между :min и :max символа.',
         'array'   => 'The :attribute must have between :min and :max items.',
     ],
-    'boolean'              => 'The :attribute field must be true or false.',
-    'confirmed'            => 'The :attribute confirmation does not match.',
-    'date'                 => 'The :attribute is not a valid date.',
-    'date_format'          => 'The :attribute does not match the format :format.',
-    'different'            => 'The :attribute and :other must be different.',
-    'digits'               => 'The :attribute must be :digits digits.',
-    'digits_between'       => 'The :attribute must be between :min and :max digits.',
-    'email'                => 'The :attribute must be a valid email address.',
-    'ends_with' => 'The :attribute must end with one of the following: :values',
-    'filled'               => 'The :attribute field is required.',
+    'boolean'              => 'Полето :attribute трябва да съдържа булева стойност (true или false).',
+    'confirmed'            => 'Потвърждението на :attribute не съвпада.',
+    'date'                 => ':attribute не е валидна дата.',
+    'date_format'          => ':attribute не е в посоченият формат - :format.',
+    'different'            => ':attribute и :other трябва да са различни.',
+    'digits'               => ':attribute трябва да съдържа :digits цифри.',
+    'digits_between'       => ':attribute трябва да бъде с дължина между :min и :max цифри.',
+    'email'                => ':attribute трябва да бъде валиден имейл адрес.',
+    'ends_with' => ':attribute трябва да свършва с един от следните символи: :values',
+    'filled'               => 'Полето :attribute е задължителен.',
     'gt'                   => [
-        'numeric' => 'The :attribute must be greater than :value.',
-        'file'    => 'The :attribute must be greater than :value kilobytes.',
-        'string'  => 'The :attribute must be greater than :value characters.',
+        'numeric' => ':attribute трябва да бъде по-голям от :value.',
+        'file'    => 'Големината на :attribute трябва да бъде по-голямо от :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-голямо от :value символа.',
         'array'   => 'The :attribute must have more than :value items.',
     ],
     'gte'                  => [
         'numeric' => 'The :attribute must be greater than or equal :value.',
-        'file'    => 'The :attribute must be greater than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be greater than or equal :value characters.',
+        'file'    => 'Големината на :attribute трябва да бъде по-голямо или равно на :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-голямо или равно на :value символа.',
         'array'   => 'The :attribute must have :value items or more.',
     ],
-    'exists'               => 'The selected :attribute is invalid.',
-    'image'                => 'The :attribute must be an image.',
-    'image_extension'      => 'The :attribute must have a valid & supported image extension.',
-    'in'                   => 'The selected :attribute is invalid.',
-    'integer'              => 'The :attribute must be an integer.',
-    'ip'                   => 'The :attribute must be a valid IP address.',
-    'ipv4'                 => 'The :attribute must be a valid IPv4 address.',
-    'ipv6'                 => 'The :attribute must be a valid IPv6 address.',
-    'json'                 => 'The :attribute must be a valid JSON string.',
+    'exists'               => 'Избраният :attribute е невалиден.',
+    'image'                => ':attribute трябва да e изображение.',
+    'image_extension'      => ':attribute трябва да е валиден и/или допустим графичен файлов формат.',
+    'in'                   => 'Избраният :attribute е невалиден.',
+    'integer'              => ':attribute трябва да бъде цяло число.',
+    'ip'                   => ':attribute трябва да бъде валиден IP адрес.',
+    'ipv4'                 => ':attribute трябва да бъде валиден IPv4 адрес.',
+    'ipv6'                 => ':attribute трябва да бъде валиден IPv6 адрес.',
+    'json'                 => ':attribute трябва да съдържа валиден JSON.',
     'lt'                   => [
-        'numeric' => 'The :attribute must be less than :value.',
-        'file'    => 'The :attribute must be less than :value kilobytes.',
-        'string'  => 'The :attribute must be less than :value characters.',
+        'numeric' => ':attribute трябва да бъде по-малко от :value.',
+        'file'    => 'Големината на :attribute трябва да бъде по-малко от :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-малко от :value символа.',
         'array'   => 'The :attribute must have less than :value items.',
     ],
     'lte'                  => [
-        'numeric' => 'The :attribute must be less than or equal :value.',
-        'file'    => 'The :attribute must be less than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be less than or equal :value characters.',
+        'numeric' => ':attribute трябва да бъде по-малко или равно на :value.',
+        'file'    => 'Големината на :attribute трябва да бъде по-малко или равно на :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-малко или равно на :value символа.',
         'array'   => 'The :attribute must not have more than :value items.',
     ],
     'max'                  => [
-        'numeric' => 'The :attribute may not be greater than :max.',
-        'file'    => 'The :attribute may not be greater than :max kilobytes.',
-        'string'  => 'The :attribute may not be greater than :max characters.',
+        'numeric' => ':attribute не трябва да бъде по-голям от :max.',
+        'file'    => 'Големината на :attribute не може да бъде по-голямо от :value килобайта.',
+        'string'  => 'Дължината на :attribute не може да бъде по-голямо от :value символа.',
         'array'   => 'The :attribute may not have more than :max items.',
     ],
     'mimes'                => 'The :attribute must be a file of type: :values.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'The :attribute must be a string.',
     'timezone'             => 'The :attribute must be a valid zone.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => 'The :attribute has already been taken.',
     'url'                  => 'The :attribute format is invalid.',
     'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
index a9c7bfb52e55005821c991eefaf2c7e521cd1ef5..38a72adf9ce6353f53ff10bb9859ee2ca746c50b 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'je kreirao/la stranicu',
-    'page_create_notification'    => 'Stranica Uspješno Kreirana',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'je ažurirao/la stranicu',
-    'page_update_notification'    => 'Stranica Uspješno Ažurirana',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'je izbrisao/la stranicu',
-    'page_delete_notification'    => 'Stranica Uspješno Izbrisana',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'je vratio/la stranicu',
-    'page_restore_notification'   => 'Stranica Uspješno Vraćena',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'je premjestio/la stranicu',
 
     // Chapters
     'chapter_create'              => 'je kreirao/la poglavlje',
-    'chapter_create_notification' => 'Poglavlje Uspješno Kreirano',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'je ažurirao/la poglavlje',
-    'chapter_update_notification' => 'Poglavlje Uspješno Ažurirano',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'je izbrisao/la poglavlje',
-    'chapter_delete_notification' => 'Poglavlje Uspješno Izbrisano',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'je premjestio/la poglavlje',
 
     // Books
     'book_create'                 => 'je kreirao/la knjigu',
-    'book_create_notification'    => 'Knjiga Uspješno Kreirana',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'je ažurirao/la knjigu',
-    'book_update_notification'    => 'Knjiga Uspješno Ažurirana',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'je izbrisao/la knjigu',
-    'book_delete_notification'    => 'Knjiga Uspješno Izbrisana',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'je sortirao/la knjigu',
-    'book_sort_notification'      => 'Knjiga Uspješno Ponovno Sortirana',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'je kreirao/la Policu za knjige',
-    'bookshelf_create_notification'    => 'Polica za knjige Uspješno Kreirana',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'je ažurirao/la policu za knjige',
-    'bookshelf_update_notification'    => 'Polica za knjige Uspješno Ažurirana',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'je izbrisao/la policu za knjige',
-    'bookshelf_delete_notification'    => 'Polica za knjige Uspješno Izbrisana',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" je dodan u tvoje favorite',
+    'favourite_remove_notification' => '":name" je uklonjen iz tvojih favorita',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'je komentarisao/la na',
index 526a8612fb9749650d8a147f96feda6727716ab0..5b01b705e0077a8c1a7e041b935619763081be76 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Lozinka',
     'password_confirm' => 'Potvrdi lozinku',
-    'password_hint' => 'Mora imati više od 7 karaktera',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Zaboravljena lozinka?',
     'remember_me' => 'Zapamti me',
     'ldap_email_hint' => 'Unesite e-mail koji će se koristiti za ovaj račun.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Ta e-mail domena nema pristup ovoj aplikaciji',
     'register_success' => 'Hvala na registraciji! Sada ste registrovani i prijavljeni.',
 
-
     // Password Reset
     'reset_password' => 'Resetuj Lozinku',
     'reset_password_send_instructions' => 'Unesite vašu e-mail adresu ispod i na nju ćemo vam poslati e-mail sa linkom za promjenu lozinke.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Primate ovaj e-mail jer smo dobili zahtjev za promjenu lozinke za vaš račun.',
     'email_reset_not_requested' => 'Ako niste zahtijevali promjenu lozinke ne trebate ništa više uraditi.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Potvrdite vaš e-mail na :appName',
     'email_confirm_greeting' => 'Hvala na pristupanju :appName!',
     'email_confirm_text' => 'Potvrdite vašu e-mail adresu pritiskom na dugme ispod:',
     'email_confirm_action' => 'Potvrdi e-mail',
     'email_confirm_send_error' => 'Potvrda e-maila je obavezna ali sistem nije mogao poslati e-mail. Kontaktirajte administratora da biste bili sigurni da je e-mail postavljen ispravno.',
-    'email_confirm_success' => 'Vaš e-mail je potvrđen!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'E-mail za potvrdu je ponovno poslan. Provjerite vaš e-mail.',
 
     'email_not_confirmed' => 'E-mail adresa nije potvrđena',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Dobrodošli na :appName!',
     'user_invite_page_text' => 'Da biste završili vaš račun i dobili pristup morate postaviti lozinku koju ćete koristiti da se prijavite na :appName tokom budućih posjeta.',
     'user_invite_page_confirm_button' => 'Potvrdi lozinku',
-    'user_invite_success' => 'Lozinka postavljena, sada imate pristup :sppName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 41d53f848e0d4edb19eeacb1c95362d2f528dc52..cd844a02e98ba08756db4cf6643b2b33a4599f2a 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Uloga',
     'cover_image' => 'Naslovna slika',
     'cover_image_description' => 'Ova slika treba biti približno 440x250px.',
-    
+
     // Actions
     'actions' => 'Akcije',
     'view' => 'Prikaz',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Resetuj',
     'remove' => 'Ukloni',
     'add' => 'Dodaj',
+    'configure' => 'Configure',
     'fullscreen' => 'Prikaz preko čitavog ekrana',
+    'favourite' => 'Favorit',
+    'unfavourite' => 'Ukloni favorit',
+    'next' => 'Sljedeće',
+    'previous' => 'Prethodno',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Opcije sortiranja',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Sortiraj uzlazno',
     'sort_descending' => 'Sortiraj silazno',
     'sort_name' => 'Ime',
-    'sort_default' => 'Default',
+    'sort_default' => 'Početne postavke',
     'sort_created_at' => 'Datum kreiranja',
     'sort_updated_at' => 'Datum ažuriranja',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Nema aktivnosti za prikazivanje',
     'no_items' => 'Nema dostupnih stavki',
     'back_to_top' => 'Povratak na vrh',
+    'skip_to_main_content' => 'Idi odmah na glavni sadržaj',
     'toggle_details' => 'Vidi detalje',
     'toggle_thumbnails' => 'Vidi prikaze slika',
     'details' => 'Detalji',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Prikaz liste',
     'default' => 'Početne postavke',
     'breadcrumb' => 'Navigacijske stavke',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Otvori meni u zaglavlju',
     'profile_menu' => 'Meni profila',
     'view_profile' => 'Pogledaj profil',
     'edit_profile' => 'Izmjeni profil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informacije',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Kartica: Prikaži dodatnu informaciju',
     'tab_content' => 'Sadržaj',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Kartica: Prikaži glavni sadržaj',
 
     // Email Content
     'email_action_help' => 'Ukoliko imate poteškoća sa pritiskom na ":actionText" dugme, kopirajte i zaljepite URL koji se nalazi ispod u vaš web pretraživač:',
index 1e5350fe509af7922b5c616a9ed911a7cd91e8f9..74f6eeb21ca3c33ba5c13bcbf2e1cabf1def7261 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Slike',
     'my_recent_drafts' => 'Moje nedavne skice',
     'my_recently_viewed' => 'Moji nedavni pregledi',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'Niste pogledali nijednu stranicu',
     'no_pages_recently_created' => 'Nijedna stranica nije napravljena nedavno',
     'no_pages_recently_updated' => 'Niijedna stranica nije ažurirana nedavno',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Sadržani web fajl',
     'export_pdf' => 'PDF fajl',
     'export_text' => 'Plain Text fajl',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Dozvole',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Bookshelf Permissions',
     'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
     'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
     'shelves_copy_permissions' => 'Copy Permissions',
     'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Poglavlja zadnja',
     'books_sort_show_other' => 'Prikaži druge knjige',
     'books_sort_save' => 'Spremi trenutni poredak',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Poglavlje',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Premjesti poglavlje',
     'chapters_move_named' => 'Premjesti poglavlje :chapterName',
     'chapter_move_success' => 'Poglavlje premješteno u :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Dozvole poglavlja',
     'chapters_empty' => 'U ovom poglavlju trenutno nema stranica.',
     'chapters_permissions_active' => 'Dozvole za poglavlje su aktivne',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nova stranica',
     'pages_editing_draft_notification' => 'Trenutno uređujete skicu koja je posljednji put snimljena :timeDiff.',
     'pages_draft_edited_notification' => 'Ova stranica je ažurirana nakon tog vremena. Preporučujemo da odbacite ovu skicu.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count korisnika je počelo sa uređivanjem ove stranice',
         'start_b' => ':userName je počeo/la sa uređivanjem ove stranice',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Dodaj nekoliko oznaka da bi sadržaj bio bolje kategorisan. \n Možeš dodati vrijednost oznaci za dublju organizaciju.",
     'tags_add' => 'Dodaj još jednu oznaku',
     'tags_remove' => 'Ukloni ovu oznaku',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Prilozi',
     'attachments_explain' => 'Učitajte fajlove ili priložite poveznice da bi ih prikazali na stranici. Oni su onda vidljivi u navigaciji sa strane.',
     'attachments_explain_instant_save' => 'Sve promjene se snimaju odmah.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
     'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
     'revision_delete_success' => 'Revision deleted',
-    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
+    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index aa16c11771fa215c8ae28ba4f1e94ab2b6dc0cb1..5c385585395e3cadc1ab121de5e7419b6f92f2b1 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'E-mail adresa za ovog korisnika nije nađena u podacima dobijenim od eksternog autentifikacijskog sistema',
     'saml_invalid_response_id' => 'Proces, koji je pokrenula ova aplikacija, nije prepoznao zahtjev od eksternog sistema za autentifikaciju. Navigacija nazad nakon prijave može uzrokovati ovaj problem.',
     'saml_fail_authed' => 'Prijava koristeći :system nije uspjela, sistem nije obezbijedio uspješnu autorizaciju',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Nema definisane akcije',
     'social_login_bad_response' => "Došlo je do greške prilikom prijave preko :socialAccount :\n:error",
     'social_account_in_use' => 'Ovaj :socialAccount račun se već koristi, pokušajte se prijaviti putem :socialAccount opcije.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Stranica nije pronađena',
     'sorry_page_not_found' => 'Stranica koju ste tražili nije pronađena.',
     'sorry_page_not_found_permission_warning' => 'Ako ste očekivali da ova stranica postoji, možda nemate privilegije da joj pristupite.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Nazad na početnu stranu',
     'error_occurred' => 'Desila se greška',
     'app_down' => ':appName trenutno nije u funkciji',
index ab7f153224afd41bb90e9cd54a9472895727b619..65e2e5264a93cce73e7a520d14acd3645a9be5bb 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Maintenance',
     'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => 'Run Cleanup',
     'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Recycle Bin',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
     'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Deleted By',
     'recycle_bin_deleted_at' => 'Deletion Time',
     'recycle_bin_permanently_delete' => 'Permanently Delete',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items to be Restored',
     'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Role Details',
     'role_name' => 'Role Name',
     'role_desc' => 'Short Description of Role',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'External Authentication IDs',
     'role_system' => 'System Permissions',
     'role_manage_users' => 'Manage users',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Manage app settings',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'User Roles',
     'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
     'users_password' => 'User Password',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
     'users_send_invite_option' => 'Send user invite email',
     'users_external_auth_id' => 'External Authentication ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Create Token',
     'users_api_tokens_expires' => 'Expires',
     'users_api_tokens_docs' => 'API Documentation',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Create API Token',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
     'user_api_token_delete_success' => 'API token successfully deleted',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index f3af075fba83007c208f7600012a8ef36866dbba..d6887ccc7f93723550fe10dc66863f0963f93e59 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute može sadržavati samo slova, brojeve, crtice i donje crtice.',
     'alpha_num'            => ':attribute može sadržavati samo slova i brojeve.',
     'array'                => ':attribute mora biti niz.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute mora biti datum prije :date.',
     'between'              => [
         'numeric' => ':attribute mora biti između :min i :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute mora biti string.',
     'timezone'             => ':attribute mora biti ispravna zona.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute je zauzet.',
     'url'                  => 'Format :attribute je neispravan.',
     'uploaded'             => 'Fajl nije učitan. Server ne prihvata fajlove ove veličine.',
index 38e4d4eb550646755aeebfbce7372735c2c320e6..725f890deb7e66458b352d03692edba083ba1934 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'ha creat la pàgina',
-    'page_create_notification'    => 'Pàgina creada correctament',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'ha actualitzat la pàgina',
-    'page_update_notification'    => 'Pàgina actualitzada correctament',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'ha suprimit una pàgina',
-    'page_delete_notification'    => 'Pàgina suprimida correctament',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'ha restaurat la pàgina',
-    'page_restore_notification'   => 'Pàgina restaurada correctament',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'ha mogut la pàgina',
 
     // Chapters
     'chapter_create'              => 'ha creat el capítol',
-    'chapter_create_notification' => 'Capítol creat correctament',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'ha actualitzat el capítol',
-    'chapter_update_notification' => 'Capítol actualitzat correctament',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'ha suprimit un capítol',
-    'chapter_delete_notification' => 'Capítol suprimit correctament',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'ha mogut el capítol',
 
     // Books
     'book_create'                 => 'ha creat el llibre',
-    'book_create_notification'    => 'Llibre creat correctament',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'ha actualitzat el llibre',
-    'book_update_notification'    => 'Llibre actualitzat correctament',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'ha suprimit un llibre',
-    'book_delete_notification'    => 'Llibre suprimit correctament',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'ha ordenat el llibre',
-    'book_sort_notification'      => 'Llibre reordenat correctament',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'ha creat el prestatge',
-    'bookshelf_create_notification'    => 'Prestatge creat correctament',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'ha actualitzat el prestatge',
-    'bookshelf_update_notification'    => 'Prestatge actualitzat correctament',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'ha suprimit un prestatge',
-    'bookshelf_delete_notification'    => 'Prestatge suprimit correctament',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'ha comentat a',
index a66f75f94ee8e96d19816457fcef07e8219d0f00..ab45b0f992ff1277d4be29c58f9e360117cb172b 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Adreça electrònica',
     'password' => 'Contrasenya',
     'password_confirm' => 'Confirmeu la contrasenya',
-    'password_hint' => 'Cal que tingui més de 7 caràcters',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Heu oblidat la contrasenya?',
     'remember_me' => 'Recorda\'m',
     'ldap_email_hint' => 'Introduïu una adreça electrònica per a aquest compte.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Aquest domini de correu electrònic no té accés a aquesta aplicació',
     'register_success' => 'Gràcies per registrar-vos! Ja us hi heu registrat i heu iniciat la sessió.',
 
-
     // Password Reset
     'reset_password' => 'Restableix la contrasenya',
     'reset_password_send_instructions' => 'Introduïu la vostra adreça electrònica a continuació i us enviarem un correu electrònic amb un enllaç per a restablir la contrasenya.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Rebeu aquest correu electrònic perquè heu rebut una petició de restabliment de contrasenya per al vostre compte.',
     'email_reset_not_requested' => 'Si no heu demanat restablir la contrasenya, no cal que prengueu cap acció.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirmeu la vostra adreça electrònica a :appName',
     'email_confirm_greeting' => 'Gràcies per unir-vos a :appName!',
     'email_confirm_text' => 'Confirmeu la vostra adreça electrònica fent clic al botó a continuació:',
     'email_confirm_action' => 'Confirma el correu',
     'email_confirm_send_error' => 'Cal confirmar l\'adreça electrònica, però el sistema no ha pogut enviar el correu electrònic. Poseu-vos en contacte amb l\'administrador perquè s\'asseguri que el correu electrònic està ben configurat.',
-    'email_confirm_success' => 'S\'ha confirmat el vostre correu electrònic!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'S\'ha tornat a enviar el correu electrònic de confirmació. Reviseu la vostra safata d\'entrada.',
 
     'email_not_confirmed' => 'Adreça electrònica no confirmada',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Us donem la benvinguda a :appName!',
     'user_invite_page_text' => 'Per a enllestir el vostre compte i obtenir-hi accés, cal que definiu una contrasenya, que es farà servir per a iniciar la sessió a :appName en futures visites.',
     'user_invite_page_confirm_button' => 'Confirma la contrasenya',
-    'user_invite_success' => 'S\'ha establert la contrasenya, ara ja teniu accés a :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 22b33de4d427194184c45f3a1e44d92573eb6bd6..21817683fc22787af54c3918d06ee4da67e60023 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rol',
     'cover_image' => 'Imatge de portada',
     'cover_image_description' => 'Aquesta imatge hauria de fer aproximadament 440x250 px.',
-    
+
     // Actions
     'actions' => 'Accions',
     'view' => 'Visualitza',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Reinicialitza',
     'remove' => 'Elimina',
     'add' => 'Afegeix',
+    'configure' => 'Configure',
     'fullscreen' => 'Pantalla completa',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Opcions d\'ordenació',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'No hi ha activitat',
     'no_items' => 'No hi ha cap element',
     'back_to_top' => 'Torna a dalt',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Commuta els detalls',
     'toggle_thumbnails' => 'Commuta les miniatures',
     'details' => 'Detalls',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Visualització en llista',
     'default' => 'Per defecte',
     'breadcrumb' => 'Ruta de navegació',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index 1426168f4b898b7e68dfdb1d283f7c4e853d8171..6ba7808b3b5ae5d6251aa78b131c1e42826391c5 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Imatges',
     'my_recent_drafts' => 'Els vostres esborranys recents',
     'my_recently_viewed' => 'Les vostres visualitzacions recents',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'No heu vist cap pàgina',
     'no_pages_recently_created' => 'No s\'ha creat cap pàgina fa poc',
     'no_pages_recently_updated' => 'No s\'ha actualitzat cap pàgina fa poc',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Fitxer web independent',
     'export_pdf' => 'Fitxer PDF',
     'export_text' => 'Fitxer de text sense format',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permisos del prestatge',
     'shelves_permissions_updated' => 'S\'han actualitzat els permisos del prestatge',
     'shelves_permissions_active' => 'S\'han activat els permisos del prestatge',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copia els permisos als llibres',
     'shelves_copy_permissions' => 'Copia els permisos',
     'shelves_copy_permissions_explain' => 'Això aplicarà la configuració de permisos actual d\'aquest prestatge a tots els llibres que contingui. Abans d\'activar-ho, assegureu-vos que hàgiu desat qualsevol canvi als permisos d\'aquest prestatge.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Els capítols al final',
     'books_sort_show_other' => 'Mostra altres llibres',
     'books_sort_save' => 'Desa l\'ordre nou',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Capítol',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Mou el capítol',
     'chapters_move_named' => 'Mou el capítol :chapterName',
     'chapter_move_success' => 'S\'ha mogut el capítol a :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Permisos del capítol',
     'chapters_empty' => 'De moment, aquest capítol no conté cap pàgina.',
     'chapters_permissions_active' => 'S\'han activat els permisos del capítol',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Pàgina nova',
     'pages_editing_draft_notification' => 'Esteu editant un esborrany que es va desar per darrer cop :timeDiff.',
     'pages_draft_edited_notification' => 'Aquesta pàgina s\'ha actualitzat d\'ençà d\'aleshores. Us recomanem que descarteu aquest esborrany.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuaris han començat a editar aquesta pàgina',
         'start_b' => ':userName ha començat a editar aquesta pàgina',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Afegiu etiquetes per a categoritzar millor el contingut. \n Podeu assignar un valor a cada etiqueta per a una organització més detallada.",
     'tags_add' => 'Afegeix una altra etiqueta',
     'tags_remove' => 'Elimina aquesta etiqueta',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Adjuncions',
     'attachments_explain' => 'Pugeu fitxers o adjunteu enllaços per a mostrar-los a la pàgina. Són visibles a la barra lateral de la pàgina.',
     'attachments_explain_instant_save' => 'Els canvis fets aquí es desen instantàniament.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Segur que voleu suprimir aquesta revisió?',
     'revision_restore_confirm' => 'Segur que voleu restaurar aquesta revisió? Se substituirà el contingut de la pàgina actual.',
     'revision_delete_success' => 'S\'ha suprimit la revisió',
-    'revision_cannot_delete_latest' => 'No es pot suprimir la darrera revisió.'
+    'revision_cannot_delete_latest' => 'No es pot suprimir la darrera revisió.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 22b8d542c1ffc8df94dfa208f00438adf2a41e88..477b59e261680efa507e65bec3d79938fb4bf6fa 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'No s\'ha pogut trobar cap adreça electrònica, per a aquest usuari, en les dades proporcionades pel sistema d\'autenticació extern',
     'saml_invalid_response_id' => 'La petició del sistema d\'autenticació extern no és reconeguda per un procés iniciat per aquesta aplicació. Aquest problema podria ser causat per navegar endarrere després d\'iniciar la sessió.',
     'saml_fail_authed' => 'L\'inici de sessió fent servir :system ha fallat, el sistema no ha proporcionat una autorització satisfactòria',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'No hi ha cap acció definida',
     'social_login_bad_response' => "S'ha rebut un error mentre s'iniciava la sessió amb :socialAccount: \n:error",
     'social_account_in_use' => 'Aquest compte de :socialAccount ja està en ús, proveu d\'iniciar la sessió mitjançant l\'opció de :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'No s\'ha trobat la pàgina',
     'sorry_page_not_found' => 'No hem pogut trobar la pàgina que cerqueu.',
     'sorry_page_not_found_permission_warning' => 'Si esperàveu que existís, és possible que no tingueu permisos per a veure-la.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Torna a l\'inici',
     'error_occurred' => 'S\'ha produït un error',
     'app_down' => ':appName està fora de servei en aquests moments',
index 6ce3b19035a76e3c06cbb3bbd9e425e3f4e86064..db20cbdc3cf0d0d77a714810d56149f7479854f9 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Manteniment',
     'maint_image_cleanup' => 'Neteja les imatges',
-    'maint_image_cleanup_desc' => "Escaneja el contingut de les pàgines i les revisions per a comprovar quines imatges i diagrames estan en ús actualment i quines imatges són redundants. Assegureu-vos de crear una còpia de seguretat completa de la base de dades i de les imatges abans d'executar això.",
+    'maint_image_cleanup_desc' => 'Escaneja el contingut de les pàgines i les revisions per a comprovar quines imatges i diagrames estan en ús actualment i quines imatges són redundants. Assegureu-vos de crear una còpia de seguretat completa de la base de dades i de les imatges abans d\'executar això.',
     'maint_delete_images_only_in_revisions' => 'Suprimeix també les imatges que només existeixin en revisions antigues de pàgines',
     'maint_image_cleanup_run' => 'Executa la neteja',
     'maint_image_cleanup_warning' => 'S\'han trobat :count imatges potencialment no utilitzades. Segur que voleu suprimir aquestes imatges?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Paperera de reciclatge',
     'recycle_bin_desc' => 'Aquí podeu restaurar els elements que hàgiu suprimit o triar suprimir-los del sistema de manera permanent. Aquesta llista no té cap filtre, al contrari que altres llistes d\'activitat similars en què es tenen en compte els filtres de permisos.',
     'recycle_bin_deleted_item' => 'Element suprimit',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Suprimit per',
     'recycle_bin_deleted_at' => 'Moment de la supressió',
     'recycle_bin_permanently_delete' => 'Suprimeix permanentment',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elements que es restauraran',
     'recycle_bin_restore_confirm' => 'Aquesta acció restaurarà l\'element suprimit, incloent-hi tots els elements fills, a la seva ubicació original. Si la ubicació original ha estat suprimida, i ara és a la paperera de reciclatge, caldrà que també en restaureu l\'element pare.',
     'recycle_bin_restore_deleted_parent' => 'El pare d\'aquest element també ha estat suprimit. L\'element es mantindrà suprimit fins que el pare també es restauri.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'S\'han suprimit :count elements en total de la paperera de reciclatge.',
     'recycle_bin_restore_notification' => 'S\'han restaurat :count elements en total de la paperera de reciclatge.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Usuari',
     'audit_table_event' => 'Esdeveniment',
     'audit_table_related' => 'Element relacionat o detall',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Data de l\'activitat',
     'audit_date_from' => 'Rang de dates a partir de',
     'audit_date_to' => 'Rang de rates fins a',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Detalls del rol',
     'role_name' => 'Nom del rol',
     'role_desc' => 'Descripció curta del rol',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Identificadors d\'autenticació externa',
     'role_system' => 'Permisos del sistema',
     'role_manage_users' => 'Gestiona usuaris',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gestiona les plantilles de pàgines',
     'role_access_api' => 'Accedeix a l\'API del sistema',
     'role_manage_settings' => 'Gestiona la configuració de l\'aplicació',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Permisos de recursos',
     'roles_system_warning' => 'Tingueu en compte que l\'accés a qualsevol dels tres permisos de dalt pot permetre que un usuari alteri els seus propis permisos o els privilegis d\'altres usuaris del sistema. Assigneu rols 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. Els permisos de llibres, capítols i pàgines tindran més importància que aquests permisos.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Rols de l\'usuari',
     'users_role_desc' => 'Seleccioneu a quins rols s\'assignarà l\'usuari. Si un usuari s\'assigna a múltiples rols, els permisos dels rols s\'acumularan i l\'usuari rebrà tots els permisos dels rols assignats.',
     'users_password' => 'Contrasenya de l\'usuari',
-    'users_password_desc' => 'Definiu una contrasenya per a iniciar la sessió a l\'aplicació. Cal que tingui un mínim de 6 caràcters.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Podeu elegir enviar un correu d\'invitació a aquest usuari, la qual cosa li permetrà definir la seva contrasenya, o podeu definir-li una contrasenya vós.',
     'users_send_invite_option' => 'Envia un correu d\'invitació a l\'usuari',
     'users_external_auth_id' => 'Identificador d\'autenticació extern',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Crea un testimoni',
     'users_api_tokens_expires' => 'Caducitat',
     'users_api_tokens_docs' => 'Documentació de l\'API',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Crea un testimoni d\'API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Segur que voleu suprimir aquest testimoni d\'API?',
     'user_api_token_delete_success' => 'Testimoni d\'API suprimit correctament',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index c134c36c6621ad28ec6bf3c51b00715c84cd0a58..603182c1a2e8c249c5d1ed66dcb4efd8313103ba 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'El camp :attribute només pot contenir lletres, números, guions i guions baixos.',
     'alpha_num'            => 'El camp :attribute només pot contenir lletres i números.',
     'array'                => 'El camp :attribute ha de ser un vector.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => 'El camp :attribute ha de ser una data anterior a :date.',
     'between'              => [
         'numeric' => 'El camp :attribute ha d\'estar entre :min i :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'El camp :attribute ha de ser una cadena.',
     'timezone'             => 'El camp :attribute ha de ser una zona vàlida.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => 'El camp :attribute ja està ocupat.',
     'url'                  => 'El format del camp :attribute no és vàlid.',
     'uploaded'             => 'No s\'ha pogut pujar el fitxer. És possible que el servidor no accepti fitxers d\'aquesta mida.',
index 818369331b6ebea37e9274da3370b717da3ca6d4..408a92773fe200d4a69f3b54bc68e25f54a02978 100644 (file)
@@ -21,8 +21,8 @@ return [
     'chapter_create_notification' => 'Kapitola byla úspěšně vytvořena',
     'chapter_update'              => 'aktualizoval/a kapitolu',
     'chapter_update_notification' => 'Kapitola byla úspěšně aktualizována',
-    'chapter_delete'              => 'smazal/a kapitolu',
-    'chapter_delete_notification' => 'Kapitola byla úspěšně smazána',
+    'chapter_delete'              => 'odstranila/a kapitolu',
+    'chapter_delete_notification' => 'Kapitola byla úspěšně odstraněna',
     'chapter_move'                => 'přesunul/a kapitolu',
 
     // Books
@@ -30,8 +30,8 @@ return [
     'book_create_notification'    => 'Kniha byla úspěšně vytvořena',
     'book_update'                 => 'aktualizoval/a knihu',
     'book_update_notification'    => 'Kniha byla úspěšně aktualizována',
-    'book_delete'                 => 'smazal/a knihu',
-    'book_delete_notification'    => 'Kniha byla úspěšně smazána',
+    'book_delete'                 => 'odstranil/a knihu',
+    'book_delete_notification'    => 'Kniha byla úspěšně odstraněna',
     'book_sort'                   => 'seřadil/a knihu',
     'book_sort_notification'      => 'Kniha byla úspěšně seřazena',
 
@@ -41,9 +41,25 @@ return [
     'bookshelf_update'                 => 'aktualizoval/a knihovnu',
     'bookshelf_update_notification'    => 'Knihovna byla úspěšně aktualizována',
     'bookshelf_delete'                 => 'odstranil/a knihovnu',
-    'bookshelf_delete_notification'    => 'Knihovna byla úspěšně odstraněna',
+    'bookshelf_delete_notification'    => 'Knihovna byla úspěšně smazána',
+
+    // Favourites
+    'favourite_add_notification' => '":name" byla přidána do Vašich oblíbených',
+    'favourite_remove_notification' => '":name" byla odstraněna z Vašich oblíbených',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Vícefaktorová metoda byla úspěšně nakonfigurována',
+    'mfa_remove_method_notification' => 'Vícefaktorová metoda byla úspěšně odstraněna',
+
+    // Webhooks
+    'webhook_create' => 'vytvořil/a webhook',
+    'webhook_create_notification' => 'Webhook byl úspěšně vytvořen',
+    'webhook_update' => 'aktualizoval/a webhook',
+    'webhook_update_notification' => 'Webhook byl úspěšně aktualizován',
+    'webhook_delete' => 'odstranil/a webhook',
+    'webhook_delete_notification' => 'Webhook byl úspěšně odstraněn',
 
     // Other
     'commented_on'                => 'okomentoval/a',
-    'permissions_update'          => 'updated permissions',
+    'permissions_update'          => 'oprávnění upravena',
 ];
index a9f36c390b9cff31c109da8256f95574e8569932..e2f92863a23555034928d374815b713978722b53 100644 (file)
@@ -6,57 +6,55 @@
  */
 return [
 
-    'failed' => 'Tyto přihlašovací údaje neodpovídají našim záznamům.',
+    'failed' => 'Neplatné přihlašovací údaje.',
     'throttle' => 'Příliš mnoho pokusů o přihlášení. Zkuste to prosím znovu za :seconds sekund.',
 
     // Login & Register
     'sign_up' => 'Registrace',
     'log_in' => 'Přihlášení',
-    'log_in_with' => 'Přihlásit se pomocí :socialDriver',
-    'sign_up_with' => 'Registrovat se pomocí :socialDriver',
+    'log_in_with' => 'Přihlásit se přes :socialDriver',
+    'sign_up_with' => 'Registrovat se přes :socialDriver',
     'logout' => 'Odhlásit',
 
     'name' => 'Jméno',
     'username' => 'Uživatelské jméno',
     'email' => 'E-mail',
     'password' => 'Heslo',
-    'password_confirm' => 'Potvrdit heslo',
-    'password_hint' => 'Musí mít více než 7 znaků',
-    'forgot_password' => 'Zapomněli jste heslo?',
+    'password_confirm' => 'Potvrzení hesla',
+    'password_hint' => 'Musí mít alespoň 8 znaků',
+    'forgot_password' => 'Zapomenuté heslo?',
     'remember_me' => 'Zapamatovat si mě',
     'ldap_email_hint' => 'Zadejte email, který chcete přiřadit k tomuto účtu.',
     'create_account' => 'Vytvořit účet',
     'already_have_account' => 'Již máte účet?',
-    'dont_have_account' => 'Nemáte účet?',
-    'social_login' => 'Přihlášení pomocí sociálních sítí',
-    'social_registration' => 'Přihlášení pomocí sociálních sítí',
-    'social_registration_text' => 'Registrovat a přihlásit se pomocí jiné služby.',
+    'dont_have_account' => 'Nemáte učet?',
+    'social_login' => 'Přihlášení přes sociální sítě',
+    'social_registration' => 'Registrace přes sociální sítě',
+    'social_registration_text' => 'Registrovat a přihlásit se přes jinou službu',
 
     'register_thanks' => 'Děkujeme za registraci!',
     'register_confirm' => 'Zkontrolujte prosím svůj e-mail a klikněte na potvrzovací tlačítko pro přístup do :appName.',
-    'registrations_disabled' => 'Registrace jsou aktuálně zakázány',
-    'registration_email_domain_invalid' => 'Tato e-mailová doména nemá přístup k této aplikaci',
+    'registrations_disabled' => 'Registrace jsou momentálně pozastaveny',
+    'registration_email_domain_invalid' => 'Registrace z této e-mailové domény nejsou povoleny',
     'register_success' => 'Děkujeme za registraci! Nyní jste zaregistrováni a přihlášeni.',
 
-
     // Password Reset
     'reset_password' => 'Obnovit heslo',
-    'reset_password_send_instructions' => 'Níže zadejte svou e-mailovou adresu a bude vám zaslán e-mail s odkazem pro obnovení hesla.',
-    'reset_password_send_button' => 'Zaslat odkaz pro obnovení',
+    'reset_password_send_instructions' => 'Níže zadejte svou e-mailovou adresu a bude vám zaslán e-mail s odkazem na obnovení hesla.',
+    'reset_password_send_button' => 'Zaslat odkaz na obnovení hesla',
     'reset_password_sent' => 'Odkaz pro obnovení hesla bude odeslán na :email, pokud bude tato e-mailová adresa nalezena v systému.',
-    'reset_password_success' => 'Vaše heslo bylo úspěšně obnoveno.',
+    'reset_password_success' => 'Vaše heslo bylo obnoveno.',
     'email_reset_subject' => 'Obnovit heslo do :appName',
     'email_reset_text' => 'Tento e-mail jste obdrželi, protože jsme obdrželi žádost o obnovení hesla k vašemu účtu.',
     'email_reset_not_requested' => 'Pokud jste o obnovení hesla nežádali, není vyžadována žádná další akce.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Potvrďte svůj e-mail pro :appName',
     'email_confirm_greeting' => 'Díky že jste se přidali do :appName!',
     'email_confirm_text' => 'Prosíme potvrďte svou e-mailovou adresu kliknutím na níže uvedené tlačítko:',
     'email_confirm_action' => 'Potvrdit e-mail',
     'email_confirm_send_error' => 'Potvrzení e-mailu je vyžadováno, ale systém nemohl odeslat e-mail. Obraťte se na správce, abyste se ujistili, že je e-mail správně nastaven.',
-    'email_confirm_success' => 'Váš e-mail byla potvrzen!',
+    'email_confirm_success' => 'Váš email byl ověřen! Nyní byste měli být schopni se touto emailovou adresou přihlásit.',
     'email_confirm_resent' => 'E-mail s potvrzením byl znovu odeslán. Zkontrolujte svou příchozí poštu.',
 
     'email_not_confirmed' => 'E-mailová adresa nebyla potvrzena',
@@ -66,12 +64,47 @@ return [
     'email_not_confirmed_resend_button' => 'Znovu odeslat potvrzovací e-mail',
 
     // User Invite
-    'user_invite_email_subject' => 'Byli jste pozváni přidat se do :appName!',
+    'user_invite_email_subject' => 'Byli jste pozváni do :appName!',
     'user_invite_email_greeting' => 'Byl pro vás vytvořen účet na :appName.',
     'user_invite_email_text' => 'Klikněte na níže uvedené tlačítko pro nastavení hesla k účtu a získání přístupu:',
     'user_invite_email_action' => 'Nastavit heslo k účtu',
     'user_invite_page_welcome' => 'Vítejte v :appName!',
-    'user_invite_page_text' => 'Pro dokončení vašeho účtu a získání přístupu musíte nastavit heslo, které bude použito k přihlášení do :appName při budoucích návštěvách.',
+    'user_invite_page_text' => 'Pro dokončení vašeho účtu a získání přístupu musíte nastavit heslo, které bude použito k přihlášení do :appName při dalších návštěvách.',
     'user_invite_page_confirm_button' => 'Potvrdit heslo',
-    'user_invite_success' => 'Heslo nastaveno, nyní máte přístup k :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Heslo bylo nasteaveno, nyní byste měli být schopni přihlásit se nastaveným heslem do aplikace :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Nastavit vícefaktorové ověření',
+    'mfa_setup_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
+    'mfa_setup_configured' => 'Již nastaveno',
+    'mfa_setup_reconfigure' => 'Přenastavit',
+    'mfa_setup_remove_confirmation' => 'Opravdu chcete odstranit tuto metodu vícefaktorového ověřování?',
+    'mfa_setup_action' => 'Nastavit',
+    'mfa_backup_codes_usage_limit_warning' => 'Zbývá vám méně než 5 záložních kódů. Před vypršením kódu si prosím vygenerujte a uložte novou sadu, abyste se vyhnuli zablokování vašeho účtu.',
+    'mfa_option_totp_title' => 'Mobilní aplikace',
+    'mfa_option_totp_desc' => 'Pro použití vícefaktorového ověření budete potřebovat mobilní aplikaci, která podporuje TOTP jako např. Google Authenticator, Authy nebo Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Záložní kódy',
+    'mfa_option_backup_codes_desc' => 'Bezpečně si uložte sadu jednorázových záložních kódů, které můžete použít pro ověření vaší identity.',
+    'mfa_gen_confirm_and_enable' => 'Potvrdit a povolit',
+    'mfa_gen_backup_codes_title' => 'Nastavení záložních kódů',
+    'mfa_gen_backup_codes_desc' => 'Uložte níže uvedený seznam kódů na bezpečné místo. Při přístupu k systému budete moci použít jeden z kódů jako druhou metodu ověření.',
+    'mfa_gen_backup_codes_download' => 'Stáhnout kódy',
+    'mfa_gen_backup_codes_usage_warning' => 'Každý kód může být použit pouze jednou',
+    'mfa_gen_totp_title' => 'Nastavení mobilní aplikace',
+    'mfa_gen_totp_desc' => 'Pro použití vícefaktorového ověření budete potřebovat mobilní aplikaci, která podporuje TOTP jako např. Google Authenticator, Authy nebo Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Ověřit nastavení',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Zde zadejte kód vygenerovaný vaší aplikací',
+    'mfa_verify_access' => 'Ověřit přístup',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'Nejsou nastaveny žádné metody',
+    '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_use_totp' => 'Ověřit pomocí mobilní aplikace',
+    'mfa_verify_use_backup_codes' => 'Ověřit pomocí záložního kódu',
+    'mfa_verify_backup_code' => 'Záložní kód',
+    'mfa_verify_backup_code_desc' => 'Níže zadejte jeden z vašich zbývajících záložních kódů:',
+    'mfa_verify_backup_code_enter_here' => 'Zde zadejte záložní kód',
+    'mfa_verify_totp_desc' => 'Níže zadejte kód, který jste si vygenerovali pomocí mobilní aplikace:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index e8a75d612c31f1f73b91b17bf214ab01d5c2782a..2a496f481ccddf215e4cb380ac5fdd8a9a1afbf5 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Role',
     'cover_image' => 'Obrázek obálky',
     'cover_image_description' => 'Obrázek by měl být přibližně 440×250px.',
-    
+
     // Actions
     'actions' => 'Akce',
     'view' => 'Zobrazit',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Obnovit',
     'remove' => 'Odebrat',
     'add' => 'Přidat',
+    'configure' => 'Nastavit',
     'fullscreen' => 'Celá obrazovka',
+    'favourite' => 'Přidat do oblíbených',
+    'unfavourite' => 'Odebrat z oblíbených',
+    'next' => 'Další',
+    'previous' => 'Předchozí',
+    'filter_active' => 'Aktivní filtr:',
+    'filter_clear' => 'Zrušit filtr',
 
     // Sort Options
     'sort_options' => 'Možnosti řazení',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Řadit vzestupně',
     'sort_descending' => 'Řadit sestupně',
     'sort_name' => 'Název',
-    'sort_default' => 'Default',
+    'sort_default' => 'Výchozí',
     'sort_created_at' => 'Datum vytvoření',
     'sort_updated_at' => 'Datum aktualizace',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Žádná aktivita k zobrazení',
     'no_items' => 'Žádné položky k dispozici',
     'back_to_top' => 'Zpět na začátek',
+    'skip_to_main_content' => 'Přeskočit na hlavní obsah',
     'toggle_details' => 'Přepnout podrobnosti',
     'toggle_thumbnails' => 'Přepnout náhledy',
     'details' => 'Podrobnosti',
@@ -63,20 +71,25 @@ return [
     'list_view' => 'Zobrazení seznamu',
     'default' => 'Výchozí',
     'breadcrumb' => 'Drobečková navigace',
+    'status' => 'Stav',
+    'status_active' => 'Aktivní',
+    'status_inactive' => 'Neaktivní',
+    'never' => 'Nikdy',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Rozbalit menu v záhlaví',
     'profile_menu' => 'Nabídka profilu',
     'view_profile' => 'Zobrazit profil',
     'edit_profile' => 'Upravit profil',
     'dark_mode' => 'Tmavý režim',
-    'light_mode' => 'Světelný režim',
+    'light_mode' => 'Světlý režim',
 
     // Layout tabs
     'tab_info' => 'Informace',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Tab: Zobrazit podružné informace',
     'tab_content' => 'Obsah',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Tab: Zobrazit hlavní obsah',
 
     // Email Content
     'email_action_help' => 'Pokud se vám nedaří kliknout na tlačítko „:actionText“, zkopírujte a vložte níže uvedenou URL do vašeho webového prohlížeče:',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Zásady ochrany osobních údajů',
+    'terms_of_service' => 'Podmínky služby',
 ];
index e06462b0021c9f9a493cc6ef52cbae494e97caf7..20df62e36066092af0f12f53691ccd55a1f2a8bb 100644 (file)
@@ -16,13 +16,13 @@ return [
     'image_image_name' => 'Název obrázku',
     'image_delete_used' => 'Tento obrázek je použit na níže uvedených stránkách.',
     'image_delete_confirm_text' => 'Opravdu chcete odstranit tento obrázek?',
-    'image_select_image' => 'Vyberte obrázek',
+    'image_select_image' => 'Zvolte obrázek',
     'image_dropzone' => 'Přetáhněte obrázky nebo klikněte sem pro nahrání',
     'images_deleted' => 'Obrázky odstraněny',
     'image_preview' => 'Náhled obrázku',
-    'image_upload_success' => 'Obrázek byl úspěšně nahrán',
-    'image_update_success' => 'Podrobnosti o obrázku byly úspěšně aktualizovány',
-    'image_delete_success' => 'Obrázek byl úspěšně odstraněn',
+    'image_upload_success' => 'Obrázek byl nahrán',
+    'image_update_success' => 'Podrobnosti o obrázku byly aktualizovány',
+    'image_delete_success' => 'Obrázek byl odstraněn',
     'image_upload_remove' => 'Odebrat',
 
     // Code Editor
index 210aca563d5ccc6eb3a577401408bca5f7cff873..78355b4b4e9b6821740493293672631b4afa5b72 100644 (file)
@@ -15,36 +15,39 @@ return [
     'recently_update' => 'Nedávno aktualizované',
     'recently_viewed' => 'Nedávno zobrazené',
     'recent_activity' => 'Nedávné aktivity',
-    'create_now' => 'Vytvořte ji nyní',
+    'create_now' => 'Vytvořit nyní',
     'revisions' => 'Revize',
     'meta_revision' => 'Revize č. :revisionCount',
     'meta_created' => 'Vytvořeno :timeLength',
     'meta_created_name' => 'Vytvořeno :timeLength uživatelem :user',
     'meta_updated' => 'Aktualizováno :timeLength',
     'meta_updated_name' => 'Aktualizováno :timeLength uživatelem :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Vlastník :user',
     'entity_select' => 'Výběr entity',
     'images' => 'Obrázky',
     'my_recent_drafts' => 'Mé nedávné koncepty',
     'my_recently_viewed' => 'Mé nedávno zobrazené',
+    'my_most_viewed_favourites' => 'Mé nejčastěji zobrazené oblíbené',
+    'my_favourites' => 'Mé oblíbené',
     'no_pages_viewed' => 'Nezobrazili jste žádné stránky',
-    'no_pages_recently_created' => 'Nedávno nebyly vytvořeny žádné stránky',
-    'no_pages_recently_updated' => 'Nedávno nebyly aktualizovány žádné stránky',
+    'no_pages_recently_created' => 'Žádné nedávno vytvořené stránky',
+    'no_pages_recently_updated' => 'Žádné nedávno aktualizované stránky',
     'export' => 'Exportovat',
-    'export_html' => 'Konsolidovaný webový soubor',
-    'export_pdf' => 'Soubor PDF',
+    'export_html' => 'HTML stránka s celým obsahem',
+    'export_pdf' => 'PDF dokument',
     'export_text' => 'Textový soubor',
+    'export_md' => 'Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Oprávnění',
     'permissions_intro' => 'Pokud je povoleno, tato oprávnění budou mít přednost před všemi nastavenými oprávněními role.',
     'permissions_enable' => 'Povolit vlastní oprávnění',
     'permissions_save' => 'Uložit oprávnění',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Vlastník',
 
     // Search
     'search_results' => 'Výsledky hledání',
-    'search_total_results_found' => 'Nalezen :count výsledek|Nalezeny :count výsledky|Nalezeny :count výsledky|Nalezeny :count výsledky|Nalezeno :count výsledků',
+    'search_total_results_found' => '{1}Nalezen :count výsledek|[2,4]Nalezeny :count výsledky|[5,*]Nalezeno :count výsledků',
     'search_clear' => 'Vymazat hledání',
     'search_no_pages' => 'Tomuto hledání neodpovídají žádné stránky',
     'search_for_term' => 'Hledat :term',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Sada oprávnění',
     'search_created_by_me' => 'Vytvořeno mnou',
     'search_updated_by_me' => 'Aktualizováno mnou',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Patřící mně',
     'search_date_options' => 'Možnosti data',
     'search_updated_before' => 'Aktualizováno před',
     'search_updated_after' => 'Aktualizováno po',
@@ -80,25 +83,26 @@ return [
     'shelves_new' => 'Nové knihovny',
     'shelves_new_action' => 'Nová Knihovna',
     'shelves_popular_empty' => 'Nejpopulárnější knihovny se objeví zde.',
-    'shelves_new_empty' => 'Zde se objeví nejnověji vytvořené knihovny.',
+    'shelves_new_empty' => 'Zde se zobrazí nejnověji vytvořené knihovny.',
     'shelves_save' => 'Uložit knihovnu',
     'shelves_books' => 'Knihy v této knihovně',
     'shelves_add_books' => 'Přidat knihy do knihovny',
-    'shelves_drag_books' => 'Knihu přidáte jejím přetažením sem.',
+    'shelves_drag_books' => 'Knihu přidáte jejím přetažením sem',
     'shelves_empty_contents' => 'Tato knihovna neobsahuje žádné knihy',
-    'shelves_edit_and_assign' => 'Pro přidáni knih do knihovny stiskněte úprvy.',
+    'shelves_edit_and_assign' => 'Upravit knihovnu a přiřadit knihy',
     'shelves_edit_named' => 'Upravit knihovnu :name',
     'shelves_edit' => 'Upravit knihovnu',
     'shelves_delete' => 'Odstranit knihovnu',
     'shelves_delete_named' => 'Odstranit knihovnu :name',
-    'shelves_delete_explain' => "Toto odstraní knihovnu s názvem ‚:name‘. Obsažené knihy nebudou odstraněny.",
+    'shelves_delete_explain' => "Toto odstraní knihovnu ‚:name‘. Vložené knihy nebudou odstraněny.",
     'shelves_delete_confirmation' => 'Opravdu chcete odstranit tuto knihovnu?',
     'shelves_permissions' => 'Oprávnění knihovny',
     'shelves_permissions_updated' => 'Oprávnění knihovny byla aktualizována',
-    'shelves_permissions_active' => 'Oprávnění knihovny jsou aktivní',
+    'shelves_permissions_active' => 'Oprávnění knihovny byla aktivována',
+    'shelves_permissions_cascade_warning' => 'Oprávnění v Knihovnách nejsou automaticky kaskádována do obsažených knih. To proto, že kniha může existovat ve více Knihovnách. Oprávnění však lze zkopírovat do podřízených knih pomocí níže uvedené možnosti.',
     'shelves_copy_permissions_to_books' => 'Kopírovat oprávnění na knihy',
     'shelves_copy_permissions' => 'Kopírovat oprávnění',
-    'shelves_copy_permissions_explain' => 'Toto použije aktuální nastavení oprávnění této knihovny na všechny knihy v ní obsažené. Před aktivací se ujistěte, že byly uloženy všechny změny oprávnění této knihovny.',
+    'shelves_copy_permissions_explain' => 'Toto použije aktuální nastavení oprávnění knihovny na všechny knihy v ní obsažené. Před aktivací se ujistěte, že byly uloženy všechny změny oprávnění této knihovny.',
     'shelves_copy_permission_success' => 'Oprávnění knihovny byla zkopírována na :count knih',
 
     // Books
@@ -110,24 +114,24 @@ return [
     'books_recent' => 'Nedávné knihy',
     'books_new' => 'Nové knihy',
     'books_new_action' => 'Nová kniha',
-    'books_popular_empty' => 'Zde se objeví nejoblíbenější knihy.',
-    'books_new_empty' => 'Zde se objeví nejnověji vytvořené knihy.',
+    'books_popular_empty' => 'Zde se zobrazí nejoblíbenější knihy.',
+    'books_new_empty' => 'Zde se zobrazí nejnověji vytvořené knihy.',
     'books_create' => 'Vytvořit novou knihu',
     'books_delete' => 'Odstranit knihu',
     'books_delete_named' => 'Odstranit knihu :bookName',
-    'books_delete_explain' => 'Toto odstraní knihu s názvem ‚:bookName‘. Všechny stránky a kapitoly budou odebrány.',
+    'books_delete_explain' => 'Toto odstraní knihu ‚:bookName‘. Všechny stránky a kapitoly v této knize budou také odstraněny.',
     'books_delete_confirmation' => 'Opravdu chcete odstranit tuto knihu?',
     'books_edit' => 'Upravit knihu',
     'books_edit_named' => 'Upravit knihu :bookName',
     'books_form_book_name' => 'Název knihy',
     'books_save' => 'Uložit knihu',
     'books_permissions' => 'Oprávnění knihy',
-    'books_permissions_updated' => 'Oprávnění knihy aktualizována',
-    'books_empty_contents' => 'Pro tuto knihu nebyly vytvořeny žádné stránky nebo kapitoly.',
+    'books_permissions_updated' => 'Oprávnění knihy byla aktualizována',
+    'books_empty_contents' => 'Pro tuto knihu nebyly vytvořeny žádné stránky ani kapitoly.',
     'books_empty_create_page' => 'Vytvořit novou stránku',
     'books_empty_sort_current_book' => 'Seřadit aktuální knihu',
     'books_empty_add_chapter' => 'Přidat kapitolu',
-    'books_permissions_active' => 'Oprávnění knihy jsou aktivní',
+    'books_permissions_active' => 'Oprávnění knihy byla aktivována',
     'books_search_this' => 'Prohledat tuto knihu',
     'books_navigation' => 'Navigace knihy',
     'books_sort' => 'Seřadit obsah knihy',
@@ -135,10 +139,12 @@ return [
     'books_sort_name' => 'Seřadit podle názvu',
     'books_sort_created' => 'Seřadit podle data vytvoření',
     'books_sort_updated' => 'Seřadit podle data aktualizace',
-    'books_sort_chapters_first' => 'Kapitoly první',
-    'books_sort_chapters_last' => 'Kapitoly poslední',
+    'books_sort_chapters_first' => 'Kapitoly jako první',
+    'books_sort_chapters_last' => 'Kapitoly jako poslední',
     'books_sort_show_other' => 'Zobrazit ostatní knihy',
     'books_sort_save' => 'Uložit nové pořadí',
+    'books_copy' => 'Kopírovat knihu',
+    'books_copy_success' => 'Kniha byla úspěšně zkopírována',
 
     // Chapters
     'chapter' => 'Kapitola',
@@ -147,20 +153,22 @@ return [
     'chapters_popular' => 'Populární kapitoly',
     'chapters_new' => 'Nová kapitola',
     'chapters_create' => 'Vytvořit novou kapitolu',
-    'chapters_delete' => 'Smazat kapitolu',
-    'chapters_delete_named' => 'Smazat kapitolu :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
-    'chapters_delete_confirm' => 'Opravdu chcete tuto kapitolu smazat?',
+    'chapters_delete' => 'Odstranit kapitolu',
+    'chapters_delete_named' => 'Odstranit kapitolu :chapterName',
+    'chapters_delete_explain' => 'Toto odstraní kapitolu ‚:chapterName‘. Všechny stránky v této kapitole budou také odstraněny.',
+    'chapters_delete_confirm' => 'Opravdu chcete odstranit tuto kapitolu?',
     'chapters_edit' => 'Upravit kapitolu',
     'chapters_edit_named' => 'Upravit kapitolu :chapterName',
     'chapters_save' => 'Uložit kapitolu',
     'chapters_move' => 'Přesunout kapitolu',
     'chapters_move_named' => 'Přesunout kapitolu :chapterName',
     'chapter_move_success' => 'Kapitola přesunuta do knihy :bookName',
-    'chapters_permissions' => 'Práva kapitoly',
+    'chapters_copy' => 'Kopírovat kapitolu',
+    'chapters_copy_success' => 'Kapitola byla úspěšně zkopírována',
+    'chapters_permissions' => 'Oprávnění kapitoly',
     'chapters_empty' => 'Tato kapitola neobsahuje žádné stránky',
-    'chapters_permissions_active' => 'Účinná práva kapitoly',
-    'chapters_permissions_success' => 'Práva kapitoly aktualizována',
+    'chapters_permissions_active' => 'Oprávnění kapitoly byla aktivována',
+    'chapters_permissions_success' => 'Oprávnění kapitoly byla aktualizována',
     'chapters_search_this' => 'Prohledat tuto kapitolu',
 
     // Pages
@@ -171,9 +179,9 @@ return [
     'pages_new' => 'Nová stránka',
     'pages_attachments' => 'Přílohy',
     'pages_navigation' => 'Obsah stránky',
-    'pages_delete' => 'Smazat stránku',
-    'pages_delete_named' => 'Smazat stránku :pageName',
-    'pages_delete_draft_named' => 'Smazat koncept stránky :pageName',
+    'pages_delete' => 'Odstranit stránku',
+    'pages_delete_named' => 'Odstranit stránku :pageName',
+    'pages_delete_draft_named' => 'Odstranit koncept stránky :pageName',
     'pages_delete_draft' => 'Odstranit koncept stránky',
     'pages_delete_success' => 'Stránka odstraněna',
     'pages_delete_draft_success' => 'Koncept stránky odstraněn',
@@ -204,17 +212,17 @@ return [
     'pages_move_success' => 'Stránka přesunuta do ":parentName"',
     'pages_copy' => 'Kopírovat stránku',
     'pages_copy_desination' => 'Cíl kopírování',
-    'pages_copy_success' => 'Stránka byla úspěšně zkopírována',
+    'pages_copy_success' => 'Stránka byla zkopírována',
     'pages_permissions' => 'Oprávnění stránky',
-    'pages_permissions_success' => 'Oprávnění stránky aktualizována',
+    'pages_permissions_success' => 'Oprávnění stránky byla aktualizována',
     'pages_revision' => 'Revize',
     'pages_revisions' => 'Revize stránky',
     'pages_revisions_named' => 'Revize stránky pro :pageName',
     'pages_revision_named' => 'Revize stránky pro :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Obnoveno z #:id; :summary',
     'pages_revisions_created_by' => 'Vytvořeno uživatelem',
     'pages_revisions_date' => 'Datum revize',
-    'pages_revisions_number' => 'Č.',
+    'pages_revisions_number' => 'Č. ',
     'pages_revisions_numbered' => 'Revize č. :id',
     'pages_revisions_numbered_changes' => 'Změny revize č. :id',
     'pages_revisions_changelog' => 'Protokol změn',
@@ -225,11 +233,12 @@ return [
     'pages_revisions_none' => 'Tato stránka nemá žádné revize',
     'pages_copy_link' => 'Kopírovat odkaz',
     'pages_edit_content_link' => 'Upravit obsah',
-    'pages_permissions_active' => 'Účinná práva stránky',
+    'pages_permissions_active' => 'Oprávnění stránky byla aktivována',
     'pages_initial_revision' => 'První vydání',
     'pages_initial_name' => 'Nová stránka',
     'pages_editing_draft_notification' => 'Právě upravujete koncept, který byl uložen před :timeDiff.',
     'pages_draft_edited_notification' => 'Tato stránka se od té doby změnila. Je doporučeno aktuální koncept zahodit.',
+    'pages_draft_page_changed_since_creation' => 'Tato stránka byla aktualizována od vytvoření tohoto konceptu. Doporučuje se zrušit tento koncept nebo se postarat o to, abyste si nepřepsali žádné již zadané změny.',
     'pages_draft_edit_active' => [
         'start_a' => 'Uživatelé začali upravovat tuto stránku (celkem :count)',
         'start_b' => ':userName začal/a upravovat tuto stránku',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Přidejte si štítky pro lepší kategorizaci knih. \n Štítky mohou nést i hodnotu pro detailnější klasifikaci.",
     'tags_add' => 'Přidat další štítek',
     'tags_remove' => 'Odstranit tento štítek',
+    'tags_usages' => 'Počet použití štítku',
+    'tags_assigned_pages' => 'Přiřazeno ke stránkám',
+    'tags_assigned_chapters' => 'Přiřazeno ke kapitolám',
+    'tags_assigned_books' => 'Přiřazeno ke knihám',
+    'tags_assigned_shelves' => 'Přiřazeno ke knihovnám',
+    'tags_x_unique_values' => ':count jedinečných hodnot',
+    'tags_all_values' => 'Všechny hodnoty',
+    'tags_view_tags' => 'Zobrazit štítky',
+    'tags_view_existing_tags' => 'Zobrazit existující štítky',
+    'tags_list_empty_hint' => 'Štítky mohou být přiřazeny pomocí postranního panelu editoru stránky nebo při úpravách podrobností knihy, kapitoly nebo knihovny.',
     'attachments' => 'Přílohy',
     'attachments_explain' => 'Nahrajte soubory nebo připojte odkazy, které se zobrazí na stránce. Budou k nalezení v postranní liště.',
     'attachments_explain_instant_save' => 'Změny zde provedené se okamžitě ukládají.',
@@ -261,8 +280,8 @@ return [
     'attachments_link' => 'Připojit odkaz',
     'attachments_set_link' => 'Nastavit odkaz',
     'attachments_delete' => 'Jste si jisti, že chcete odstranit tuto přílohu?',
-    'attachments_dropzone' => 'Přetáhněte sem soubory myší nebo sem kliknětě pro vybrání souboru.',
-    'attachments_no_files' => 'Žádné soubory nebyli nahrány',
+    'attachments_dropzone' => 'Přetáhněte sem soubory myší nebo sem klikněte pro vybrání souboru',
+    'attachments_no_files' => 'Žádné soubory nebyly nahrány',
     'attachments_explain_link' => 'Můžete pouze připojit odkaz pokud nechcete nahrávat soubor přímo. Může to být odkaz na jinou stránku nebo na soubor v cloudu.',
     'attachments_link_name' => 'Název odkazu',
     'attachment_link' => 'Odkaz na přílohu',
@@ -272,13 +291,13 @@ return [
     'attachments_insert_link' => 'Přidat odkaz na přílohu do stránky',
     'attachments_edit_file' => 'Upravit soubor',
     'attachments_edit_file_name' => 'Název souboru',
-    'attachments_edit_drop_upload' => 'Přetáhněte sem soubor myší nebo klikněte pro nahrání nového a následné přepsání starého.',
+    'attachments_edit_drop_upload' => 'Přetáhněte sem soubor myší nebo klikněte pro nahrání nového souboru a následné přepsání starého',
     'attachments_order_updated' => 'Pořadí příloh aktualizováno',
     'attachments_updated_success' => 'Podrobnosti příloh aktualizovány',
-    'attachments_deleted' => 'Příloha byla smazána',
-    'attachments_file_uploaded' => 'Soubor byl úspěšně nahrán',
-    'attachments_file_updated' => 'Soubor byl úspěšně aktualizován',
-    'attachments_link_attached' => 'Odkaz úspěšně přiložen ke stránce',
+    'attachments_deleted' => 'Příloha byla odstraněna',
+    'attachments_file_uploaded' => 'Soubor byl nahrán',
+    'attachments_file_updated' => 'Soubor byl aktualizován',
+    'attachments_link_attached' => 'Odkaz byl přiložen ke stránce',
     'templates' => 'Šablony',
     'templates_set_as_template' => 'Tato stránka je šablona',
     'templates_explain_set_as_template' => 'Tuto stránku můžete nastavit jako šablonu, aby byl její obsah využit při vytváření dalších stránek. Ostatní uživatelé budou moci použít tuto šablonu, pokud mají oprávnění k zobrazení této stránky.',
@@ -298,7 +317,7 @@ return [
     'comment' => 'Komentář',
     'comments' => 'Komentáře',
     'comment_add' => 'Přidat komentář',
-    'comment_placeholder' => 'Zanechat komentář zde',
+    'comment_placeholder' => 'Zde zadejte komentář',
     'comment_count' => '{0} Bez komentářů|{1} 1 komentář|[2,4] :count komentáře|[5,*] :count komentářů',
     'comment_save' => 'Uložit komentář',
     'comment_saving' => 'Ukládání komentáře...',
@@ -306,15 +325,23 @@ return [
     'comment_new' => 'Nový komentář',
     'comment_created' => 'komentováno :createDiff',
     'comment_updated' => 'Aktualizováno :updateDiff uživatelem :username',
-    'comment_deleted_success' => 'Komentář smazán',
+    'comment_deleted_success' => 'Komentář odstraněn',
     'comment_created_success' => 'Komentář přidán',
     'comment_updated_success' => 'Komentář aktualizován',
-    'comment_delete_confirm' => 'Opravdu chcete smazat tento komentář?',
+    'comment_delete_confirm' => 'Opravdu chcete odstranit tento komentář?',
     'comment_in_reply_to' => 'Odpověď na :commentId',
 
     // Revision
-    'revision_delete_confirm' => 'Opravdu chcete smazat tuto revizi?',
+    'revision_delete_confirm' => 'Opravdu chcete odstranit tuto revizi?',
     'revision_restore_confirm' => 'Jste si jisti, že chcete obnovit tuto revizi? Aktuální obsah stránky bude nahrazen.',
-    'revision_delete_success' => 'Revize smazána',
-    'revision_cannot_delete_latest' => 'Nelze smazat poslední revizi.'
+    'revision_delete_success' => 'Revize odstraněna',
+    'revision_cannot_delete_latest' => 'Nelze odstranit poslední revizi.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Vlastní nastavení oprávnění nebudou zkopírovány.',
+    'copy_consider_owner' => 'Stanete se vlastníkem veškerého kopírovaného obsahu.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Přílohy stránky nebudou zkopírovány.',
+    'copy_consider_access' => 'Po změně umístění, vlastníka nebo oprávnění může dojít k tomu, že obsah může být přístupný těm, kteří přístup dříve něměli.',
 ];
index 43ba536f0496236363baa7f18e1ff4cf17b398e3..54b18766cd6730746abb690781b0c3853acc67ff 100644 (file)
@@ -5,14 +5,14 @@
 return [
 
     // Permissions
-    'permission' => 'Nemáte povolení přistupovat na dotazovanou stránku.',
+    'permission' => 'Nemáte povolení přistupovat na požadovanou stránku.',
     'permissionJson' => 'Nemáte povolení k provedení požadované akce.',
 
     // Auth
     'error_user_exists_different_creds' => 'Uživatel s emailem :email již existuje ale s jinými přihlašovacími údaji.',
     'email_already_confirmed' => 'Emailová adresa již byla potvrzena. Zkuste se přihlásit.',
     'email_confirmation_invalid' => 'Tento potvrzovací odkaz již neplatí nebo už byl použit. Zkuste prosím registraci znovu.',
-    'email_confirmation_expired' => 'Potvrzovací odkaz už neplatí, email s novým odkazem už byl poslán.',
+    'email_confirmation_expired' => 'Tento potvrzovací odkaz již neplatí, byl Vám odeslán nový potvrzovací e-mail.',
     'email_confirmation_awaiting' => 'E-mailová adresa pro používaný účet musí být potvrzena',
     'ldap_fail_anonymous' => 'Přístup k adresáři LDAP jako anonymní uživatel (anonymous bind) selhal',
     'ldap_fail_authed' => 'Přístup k adresáři LDAP pomocí zadaného jména (dn) a hesla selhal',
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Nelze najít e-mailovou adresu pro tohoto uživatele v datech poskytnutých externím přihlašovacím systémem',
     'saml_invalid_response_id' => 'Požadavek z externího ověřovacího systému nebyl rozpoznám procesem, který tato aplikace spustila. Tento problém může způsobit stisknutí tlačítka Zpět po přihlášení.',
     'saml_fail_authed' => 'Přihlášení pomocí :system selhalo, systém neposkytl úspěšnou autorizaci',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Nebyla zvolena žádá akce',
     'social_login_bad_response' => "Nastala chyba během přihlašování přes :socialAccount \n:error",
     'social_account_in_use' => 'Tento účet na :socialAccount se již používá. Pokuste se s ním přihlásit volbou Přihlásit přes :socialAccount.',
@@ -50,7 +54,7 @@ return [
 
     // Pages
     'page_draft_autosave_fail' => 'Nepovedlo se uložit koncept. Než stránku uložíte, ujistěte se, že jste připojeni k internetu.',
-    'page_custom_home_deletion' => 'Nelze smazat tuto stránku, protože je nastavena jako uvítací stránka.',
+    'page_custom_home_deletion' => 'Nelze odstranit tuto stránku, protože je nastavena jako uvítací stránka',
 
     // Entities
     'entity_not_found' => 'Prvek nenalezen',
@@ -60,36 +64,39 @@ return [
     'chapter_not_found' => 'Kapitola nenalezena',
     'selected_book_not_found' => 'Vybraná kniha nebyla nalezena',
     'selected_book_chapter_not_found' => 'Zvolená kniha nebo kapitola nebyla nalezena',
-    'guests_cannot_save_drafts' => 'Návštěvníci z řad veřejnosti nemohou ukládat koncepty.',
+    'guests_cannot_save_drafts' => 'Nepřihlášení návštěvníci nemohou ukládat koncepty',
 
     // Users
-    'users_cannot_delete_only_admin' => 'Nemůžete smazat posledního administrátora',
-    'users_cannot_delete_guest' => 'Uživatele host není možno smazat',
+    'users_cannot_delete_only_admin' => 'Nemůžete odstranit posledního administrátora',
+    'users_cannot_delete_guest' => 'Uživatele Host není možno odstranit',
 
     // Roles
     'role_cannot_be_edited' => 'Tuto roli nelze editovat',
-    'role_system_cannot_be_deleted' => 'Toto je systémová role a nelze jí smazat.',
-    'role_registration_default_cannot_delete' => 'Tuto roli nelze smazat dokud je nastavená jako výchozí role pro registraci nových uživatelů.',
+    'role_system_cannot_be_deleted' => 'Toto je systémová role a nelze jí odstranit',
+    'role_registration_default_cannot_delete' => 'Tuto roli nelze odstranit dokud je nastavená jako výchozí role pro registraci nových uživatelů',
     'role_cannot_remove_only_admin' => 'Tento uživatel má roli administrátora. Přiřaďte roli administrátora někomu jinému než jí odeberete zde.',
 
     // Comments
-    'comment_list' => 'Při dotahování komentářů nastala chyba.',
+    'comment_list' => 'Při načítání komentářů nastala chyba.',
     'cannot_add_comment_to_draft' => 'Nemůžete přidávat komentáře ke konceptu.',
     'comment_add' => 'Při přidávání / aktualizaci komentáře nastala chyba.',
-    'comment_delete' => 'Při mazání komentáře nastala chyba.',
+    'comment_delete' => 'Při odstraňování komentáře nastala chyba.',
     'empty_comment' => 'Nemůžete přidat prázdný komentář.',
 
     // Error pages
     '404_page_not_found' => 'Stránka nenalezena',
-    'sorry_page_not_found' => 'Omlouváme se, ale stránka, kterou hledáte nebyla nalezena.',
+    'sorry_page_not_found' => 'Omlouváme se, ale stránka, kterou hledáte, nebyla nalezena.',
     'sorry_page_not_found_permission_warning' => 'Pokud očekáváte, že by stránka měla existovat, možná jen nemáte oprávnění pro její zobrazení.',
+    'image_not_found' => 'Obrázek nenalezen',
+    'image_not_found_subtitle' => 'Omlouváme se, ale obrázek, který hledáte, nebyl nalezen.',
+    'image_not_found_details' => 'Pokud očekáváte, že by obrázel měl existovat, tak byl zřejmě již odstraněn.',
     'return_home' => 'Návrat domů',
     'error_occurred' => 'Nastala chyba',
     'app_down' => ':appName je momentálně vypnutá',
-    'back_soon' => 'Brzy naběhne.',
+    'back_soon' => 'Brzy bude opět v provozu.',
 
     // API errors
-    'api_no_authorization_found' => 'V požadavku nebyla nalezen žádný autorizační token',
+    'api_no_authorization_found' => 'V požadavku nebyl nalezen žádný autorizační token',
     'api_bad_authorization_format' => 'V požadavku byl nalezen autorizační token, ale jeho formát se zdá být chybný',
     'api_user_token_not_found' => 'Pro zadaný autorizační token nebyl nalezen žádný odpovídající API token',
     'api_incorrect_token_secret' => 'Poskytnutý Token Secret neodpovídá použitému API tokenu',
index cc5669d4825d6e4892c20e9b149d2879c6bdcbdd..40b12b6070fe64cc7fa4f623ab83abadc3b13c4f 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => 'Heslo musí mít alespoň osm znaků a musí odpovídat potvrzení.',
+    'password' => 'Heslo musí mít alespoň osm znaků a musí odpovídat potvrzení hesla.',
     'user' => "Nemůžeme nalézt uživatele s touto e-mailovou adresou.",
-    'token' => 'Token pro obnovení hesla je neplatný pro tuto e-mailovou adresu.',
-    'sent' => 'Poslali jsme vám e-mail s odkazem pro obnovení hesla!',
+    'token' => 'Token pro obnovení hesla není platný pro tuto e-mailovou adresu.',
+    'sent' => 'Poslali jsme Vám e-mail s odkazem pro obnovení hesla!',
     'reset' => 'Vaše heslo bylo obnoveno!',
 
 ];
index 1dc4000aae3e94f7402ee856db318543c932fa80..3d8aa0c6dba518200f43e138ce72f772a20bdffb 100644 (file)
@@ -35,13 +35,13 @@ return [
     'app_primary_color' => 'Hlavní barva aplikace',
     'app_primary_color_desc' => 'Nastaví hlavní barvu aplikace včetně panelů, tlačítek a odkazů.',
     'app_homepage' => 'Úvodní stránka aplikace',
-    'app_homepage_desc' => 'Vyberte si zobrazení, které se použije jako úvodní stránka. U zvolených stránek bude ignorováno jejich oprávnění.',
+    'app_homepage_desc' => 'Zvolte si zobrazení, které se použije jako úvodní stránka. U zvolených stránek bude ignorováno jejich oprávnění.',
     'app_homepage_select' => 'Zvolte stránku',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
-    'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_footer_links' => 'Odkazy v zápatí',
+    'app_footer_links_desc' => 'Přidejte odkazy, které se zobrazí v zápatí webu. Ty se zobrazí ve spodní části většiny stránek, včetně těch, které nevyžadují přihlášení. K použití překladů definovaných systémem můžete použít štítek „trans::<key>“. Například: Použití „trans::common.privacy_policy“ přeloží text na „Zásady ochrany osobních údajů“ a „trans::common.terms_of_service“ poskytne přeložený text „Podmínky služby“.',
+    'app_footer_links_label' => 'Text odkazu',
+    'app_footer_links_url' => 'URL odkazu',
+    'app_footer_links_add' => 'Přidat odkaz do zápatí',
     'app_disable_comments' => 'Vypnutí komentářů',
     'app_disable_comments_toggle' => 'Vypnout komentáře',
     'app_disable_comments_desc' => 'Vypne komentáře napříč všemi stránkami. <br> Existující komentáře se přestanou zobrazovat.',
@@ -72,11 +72,11 @@ return [
     // Maintenance settings
     'maint' => 'Údržba',
     'maint_image_cleanup' => 'Pročistění obrázků',
-    'maint_image_cleanup_desc' => "Prohledá stránky a jejich revize, aby zjistil, které obrázky a kresby jsou momentálně používány a které jsou zbytečné. Zajistěte plnou zálohu databáze a obrázků než se do toho pustíte.",
-    'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
+    'maint_image_cleanup_desc' => 'Prohledá stránky a jejich revize, aby zjistil, které obrázky a kresby jsou momentálně používány a které jsou zbytečné. Zajistěte plnou zálohu databáze a obrázků než se do toho pustíte.',
+    'maint_delete_images_only_in_revisions' => 'Odstranit i obrázky, které se vyskytují pouze ve starých revizích stránky',
     'maint_image_cleanup_run' => 'Spustit pročištění',
-    'maint_image_cleanup_warning' => 'Nalezeno :count potenciálně nepoužitých obrázků. Jste si jistí, že je chcete smazat?',
-    'maint_image_cleanup_success' => 'Potenciálně nepoužité obrázky byly smazány. Celkem :count.',
+    'maint_image_cleanup_warning' => 'Nalezeno :count potenciálně nepoužitých obrázků. Jste si jisti, že je chcete odstranit?',
+    'maint_image_cleanup_success' => 'Nalezeno :count potenciálně nepoužitých obrázků a všechny byly odstraněny!',
     'maint_image_cleanup_nothing_found' => 'Žádné potenciálně nepoužité obrázky nebyly nalezeny. Nic nebylo smazáno.',
     'maint_send_test_email' => 'Odeslat zkušební e-mail',
     'maint_send_test_email_desc' => 'Toto pošle zkušební e-mail na vaši e-mailovou adresu uvedenou ve vašem profilu.',
@@ -85,27 +85,29 @@ return [
     'maint_send_test_email_mail_subject' => 'Testovací e-mail',
     'maint_send_test_email_mail_greeting' => 'Zdá se, že posílání e-mailů funguje!',
     'maint_send_test_email_mail_text' => 'Gratulujeme! Protože jste dostali tento e-mail, zdá se, že nastavení e-mailů je v pořádku.',
-    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
-    'maint_recycle_bin_open' => 'Open Recycle Bin',
+    'maint_recycle_bin_desc' => 'Odstraněné knihovny, knihy, kapitoly a stránky se přesouvají do Koše, aby je bylo možné obnovit nebo trvale smazat. Starší položky v koši mohou být po čase automaticky odstraněny v závislosti na konfiguraci systému.',
+    'maint_recycle_bin_open' => 'Otevřít Koš',
 
     // Recycle Bin
-    'recycle_bin' => 'Recycle Bin',
-    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
-    'recycle_bin_deleted_by' => 'Deleted By',
-    'recycle_bin_deleted_at' => 'Deletion Time',
-    'recycle_bin_permanently_delete' => 'Permanently Delete',
-    'recycle_bin_restore' => 'Restore',
-    'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
-    'recycle_bin_empty' => 'Empty Recycle Bin',
-    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
-    'recycle_bin_restore_list' => 'Items to be Restored',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
-    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin' => 'Koš',
+    'recycle_bin_desc' => 'Zde můžete obnovit položky, které byly odstraněny, nebo zvolit jejich trvalé odstranění ze systému. Tento seznam je nefiltrovaný, na rozdíl od podobných seznamů aktivit v systému, kde jsou použity filtry podle oprávnění.',
+    'recycle_bin_deleted_item' => 'Odstraněná položka',
+    'recycle_bin_deleted_parent' => 'Nadřazená položka',
+    'recycle_bin_deleted_by' => 'Odstranil/a',
+    'recycle_bin_deleted_at' => 'Čas odstranění',
+    'recycle_bin_permanently_delete' => 'Trvale odstranit',
+    'recycle_bin_restore' => 'Obnovit',
+    'recycle_bin_contents_empty' => 'Koš je nyní prázdný',
+    'recycle_bin_empty' => 'Vysypat Koš',
+    'recycle_bin_empty_confirm' => 'Toto trvale odstraní všechny položky v Koši včetně obsahu vloženého v každé položce. Opravdu chcete vysypat Koš?',
+    'recycle_bin_destroy_confirm' => 'Tato akce trvale odstraní ze systému tuto položku spolu s veškerým vloženým obsahem a tento obsah nebudete moci obnovit. Opravdu chcete tuto položku trvale odstranit?',
+    'recycle_bin_destroy_list' => 'Položky k trvalému odstranění',
+    'recycle_bin_restore_list' => 'Položky k obnovení',
+    'recycle_bin_restore_confirm' => 'Tato akce obnoví odstraněnou položku včetně veškerého vloženého obsahu do původního umístění. Pokud bylo původní umístění od té doby odstraněno a nyní je v Koši, bude také nutné obnovit nadřazenou položku.',
+    'recycle_bin_restore_deleted_parent' => 'Nadřazená položka této položky byla také odstraněna. Ty zůstanou odstraněny, dokud nebude obnoven i nadřazený objekt.',
+    'recycle_bin_restore_parent' => 'Obnovit nadřazenu položku',
+    'recycle_bin_destroy_notification' => 'Celkem odstraněno :count položek z Koše.',
+    'recycle_bin_restore_notification' => 'Celkem obnoveno :count položek z Koše.',
 
     // Audit Log
     'audit' => 'Protokol auditu',
@@ -116,7 +118,8 @@ return [
     'audit_deleted_item_name' => 'Jméno: :name',
     'audit_table_user' => 'Uživatel',
     'audit_table_event' => 'Událost',
-    'audit_table_related' => 'Related Item or Detail',
+    'audit_table_related' => 'Související položka nebo detail',
+    'audit_table_ip' => 'IP adresa',
     'audit_table_date' => 'Datum aktivity',
     'audit_date_from' => 'Časový rozsah od',
     'audit_date_to' => 'Časový rozsah do',
@@ -125,17 +128,18 @@ return [
     'roles' => 'Role',
     'role_user_roles' => 'Uživatelské role',
     'role_create' => 'Vytvořit novou roli',
-    'role_create_success' => 'Role byla úspěšně vytvořena',
-    'role_delete' => 'Smazat roli',
-    'role_delete_confirm' => 'Role \':roleName\' bude smazána.',
+    'role_create_success' => 'Role byla vytvořena',
+    'role_delete' => 'Odstranit roli',
+    'role_delete_confirm' => 'Role \':roleName\' bude odstraněna.',
     'role_delete_users_assigned' => 'Role je přiřazena :userCount uživatelům. Pokud jim chcete náhradou přidělit jinou roli, zvolte jednu z následujících.',
     'role_delete_no_migration' => "Nepřiřazovat uživatelům náhradní roli",
-    'role_delete_sure' => 'Opravdu chcete tuto roli smazat?',
-    'role_delete_success' => 'Role byla úspěšně smazána',
+    'role_delete_sure' => 'Opravdu chcete tuto roli odstranit?',
+    'role_delete_success' => 'Role byla odstraněna',
     'role_edit' => 'Upravit roli',
     'role_details' => 'Detaily role',
     'role_name' => 'Název role',
     'role_desc' => 'Stručný popis role',
+    'role_mfa_enforced' => 'Vyžaduje Vícefaktorové ověření',
     'role_external_auth_id' => 'Přihlašovací identifikátory třetích stran',
     'role_system' => 'Systémová oprávnění',
     'role_manage_users' => 'Správa uživatelů',
@@ -145,15 +149,16 @@ return [
     'role_manage_page_templates' => 'Správa šablon stránek',
     'role_access_api' => 'Přístup k systémovému API',
     'role_manage_settings' => 'Správa nastavení aplikace',
+    'role_export_content' => 'Exportovat obsah',
     '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 práva řídí přístup k obsahu napříč systémem. Specifická práva na knihách, kapitolách a stránkách převáží tato nastavení.',
+    '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_all' => 'Vše',
     'role_own' => 'Vlastní',
     'role_controlled_by_asset' => 'Řídí se obsahem, do kterého jsou nahrávány',
     'role_save' => 'Uložit roli',
-    'role_update_success' => 'Role úspěšně aktualizována',
+    'role_update_success' => 'Role byla aktualizována',
     'role_users' => 'Uživatelé mající tuto roli',
     'role_users_none' => 'Žádný uživatel nemá tuto roli',
 
@@ -162,67 +167,99 @@ return [
     'user_profile' => 'Profil uživatele',
     'users_add_new' => 'Přidat nového uživatele',
     'users_search' => 'Vyhledávání uživatelů',
-    'users_latest_activity' => 'Latest Activity',
+    'users_latest_activity' => 'Nedávná aktivita',
     'users_details' => 'Údaje o uživateli',
     'users_details_desc' => 'Nastavte zobrazované jméno a e-mailovou adresu pro tohoto uživatele. E-mailová adresa bude použita pro přihlášení do aplikace.',
     'users_details_desc_no_email' => 'Nastavte zobrazované jméno pro tohoto uživatele, aby jej ostatní uživatele poznali.',
     'users_role' => 'Uživatelské role',
     'users_role_desc' => 'Zvolte role, do kterých chcete uživatele zařadit. Pokud bude uživatel zařazen do více rolí, oprávnění z těchto rolí se sloučí a uživateli bude dovoleno vše, k čemu mají jednotlivé role oprávnění.',
     'users_password' => 'Heslo uživatele',
-    'users_password_desc' => 'Zadejte heslo pro přihlášení do aplikace. Heslo musí být nejméně 6 znaků dlouhé.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Uživateli můžete poslat pozvánku e-mailem, která umožní uživateli, aby si zvolil sám svoje heslo do aplikace a nebo můžete zadat heslo sami.',
     'users_send_invite_option' => 'Poslat uživateli pozvánku e-mailem',
     'users_external_auth_id' => 'Přihlašovací identifikátor třetích stran',
     'users_external_auth_id_desc' => 'ID použité pro rozpoznání tohoto uživatele když komunikuje s externím přihlašovacím systémem.',
     'users_password_warning' => 'Vyplňujte pouze v případě, že chcete heslo změnit.',
     'users_system_public' => 'Symbolizuje každého nepřihlášeného návštěvníka, který navštívil aplikaci. Nelze ho použít k přihlášení ale je přiřazen automaticky nepřihlášeným.',
-    'users_delete' => 'Smazat uživatele',
+    'users_delete' => 'Odstranit uživatele',
     'users_delete_named' => 'Odstranit uživatele :userName',
-    'users_delete_warning' => 'Uživatel \':userName\' bude zcela smazán ze systému.',
+    'users_delete_warning' => 'Uživatel \':userName\' bude zcela odstraněn ze systému.',
     'users_delete_confirm' => 'Opravdu chcete tohoto uživatele smazat?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
-    'users_none_selected' => 'No user selected',
-    'users_delete_success' => 'User successfully removed',
+    'users_migrate_ownership' => 'Převést vlastnictví',
+    'users_migrate_ownership_desc' => 'Zde zvolte jiného uživatele, pokud chcete, aby se stal vlastníkem všech položek aktuálně vlastněných tímto uživatelem.',
+    'users_none_selected' => 'Nebyl zvolen žádný uživatel',
+    'users_delete_success' => 'Uživatel byl odstraněn',
     'users_edit' => 'Upravit uživatele',
     'users_edit_profile' => 'Upravit profil',
     'users_edit_success' => 'Uživatel byl úspěšně aktualizován',
     'users_avatar' => 'Obrázek uživatele',
-    'users_avatar_desc' => 'Vyberte obrázek, který bude reprezentovat tohoto uživatele. Měl by být přibližně 256px velký ve tvaru čtverce.',
+    'users_avatar_desc' => 'Zvolte obrázek, který bude reprezentovat tohoto uživatele. Měl by být přibližně 256px velký ve tvaru čtverce.',
     'users_preferred_language' => 'Preferovaný jazyk',
     'users_preferred_language_desc' => 'Tato volba ovlivní pouze jazyk používaný v uživatelském rozhraní aplikace. Volba nemá vliv na žádný uživateli vytvářený obsah.',
     'users_social_accounts' => 'Sociální účty',
     'users_social_accounts_info' => 'Zde můžete přidat vaše účty ze sociálních sítí pro pohodlnější přihlašování. Odpojení účtů neznamená, že tato aplikace ztratí práva číst detaily z vašeho účtu. Zakázat této aplikaci přístup k detailům vašeho účtu musíte přímo ve svém profilu na dané sociální síti.',
     'users_social_connect' => 'Připojit účet',
     'users_social_disconnect' => 'Odpojit účet',
-    'users_social_connected' => 'Účet :socialAccount byl úspěšně připojen k vašemu profilu.',
-    'users_social_disconnected' => 'Účet :socialAccount byl úspěšně odpojen od vašeho profilu.',
+    'users_social_connected' => 'Účet :socialAccount byl připojen k vašemu profilu.',
+    'users_social_disconnected' => 'Účet :socialAccount byl odpojen od vašeho profilu.',
     'users_api_tokens' => 'API Tokeny',
     'users_api_tokens_none' => 'Tento uživatel nemá vytvořené žádné API Tokeny',
     'users_api_tokens_create' => 'Vytvořit Token',
     'users_api_tokens_expires' => 'Vyprší',
-    'users_api_tokens_docs' => 'API Dokumentace',
+    'users_api_tokens_docs' => 'Dokumentace API',
+    'users_mfa' => 'Vícefázové ověření',
+    'users_mfa_desc' => 'Nastavit vícefaktorové ověřování jako další vrstvu zabezpečení vašeho uživatelského účtu.',
+    'users_mfa_x_methods' => ':count nastavená metoda|:count nastavených metod',
+    'users_mfa_configure' => 'Konfigurovat metody',
 
     // API Tokens
-    'user_api_token_create' => 'Vytvořit API Klíč',
+    'user_api_token_create' => 'Vytvořit API Token',
     'user_api_token_name' => 'Název',
     'user_api_token_name_desc' => 'Zadejte srozumitelný název tokenu, který vám později může pomoci připomenout účel, za jakým jste token vytvářeli.',
     'user_api_token_expiry' => 'Platný do',
     'user_api_token_expiry_desc' => 'Zadejte datum, kdy platnost tokenu vyprší. Po tomto datu nebudou požadavky, které používají tento token, fungovat. Pokud ponecháte pole prázdné, bude tokenu nastavena platnost na dalších 100 let.',
     'user_api_token_create_secret_message' => 'Ihned po vytvoření tokenu Vám bude vygenerován a zobrazen "Token ID" a "Token Secret". Upozorňujeme, že "Token Secret" bude možné zobrazit pouze jednou, ujistěte se, že si jej poznamenáte a uložíte na bezpečné místo před tím, než budete pokračovat dále.',
-    'user_api_token_create_success' => 'API klíč úspěšně vytvořen',
-    'user_api_token_update_success' => 'API klíč úspěšně aktualizován',
-    'user_api_token' => 'API Klíč',
+    'user_api_token_create_success' => 'API Token byl vytvořen',
+    'user_api_token_update_success' => 'API Token byl aktualizován',
+    'user_api_token' => 'API Token',
     'user_api_token_id' => 'Token ID',
-    'user_api_token_id_desc' => 'Toto je neupravitelný systémový identifikátor generovaný pro tento klíč, který musí být uveden v API requestu.',
+    'user_api_token_id_desc' => 'Toto je neupravitelný systémový identifikátor generovaný pro tento Token, který musí být uveden v API requestu.',
     'user_api_token_secret' => 'Token Secret',
-    'user_api_token_secret_desc' => 'Toto je systémem generovaný "secret" pro tento klíč, který musí být v API requestech. Toto bude zobrazeno pouze jednou, takže si uložte tuto hodnotu na bezpečné místo.',
+    'user_api_token_secret_desc' => 'Toto je systémem generovaný "Secret" pro tento Token, který musí být v API requestech. Toto bude zobrazeno pouze jednou, takže si uložte tuto hodnotu na bezpečné místo.',
     'user_api_token_created' => 'Token vytvořen :timeAgo',
     'user_api_token_updated' => 'Token aktualizován :timeAgo',
     'user_api_token_delete' => 'Odstranit Token',
-    'user_api_token_delete_warning' => 'Tímto plně smažete tento API klíč s názvem \':tokenName\' ze systému.',
-    'user_api_token_delete_confirm' => 'Opravdu chcete odstranit tento API klíč?',
-    'user_api_token_delete_success' => 'API Klíč úspěšně odstraněn',
+    'user_api_token_delete_warning' => 'Tímto plně odstraníte tento API Token s názvem \':tokenName\' ze systému.',
+    'user_api_token_delete_confirm' => 'Opravdu chcete odstranit tento API Token?',
+    'user_api_token_delete_success' => 'API Token byl odstraněn',
+
+    // Webhooks
+    'webhooks' => 'Webhooky',
+    'webhooks_create' => 'Vytvořit nový webhook',
+    'webhooks_none_created' => 'Žádné webhooky nebyly doposud vytvořeny.',
+    'webhooks_edit' => 'Upravit webhook',
+    'webhooks_save' => 'Uložit webhook',
+    'webhooks_details' => 'Podrobnosti webhooku',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Události webhooku',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'Všechny události systému',
+    'webhooks_name' => 'Název webhooku',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook aktivní',
+    'webhook_events_table_header' => 'Události',
+    'webhooks_delete' => 'Odstranit webhook',
+    'webhooks_delete_warning' => 'Webhook s názvem \':webhookName\' bude úplně odstraněn ze systému.',
+    'webhooks_delete_confirm' => 'Opravdu chcete odstranit tento webhook?',
+    'webhooks_format_example' => 'Příklad formátu webhooku',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 1a94b9e24f210a7c17eea9c3328ee1b2364ffdc9..e4e33bc0c21dabcaa4c562ed2c541efd416b89cc 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute může obsahovat pouze písmena, číslice, pomlčky a podtržítka. České znaky (á, é, í, ó, ú, ů, ž, š, č, ř, ď, ť, ň) nejsou podporovány.',
     'alpha_num'            => ':attribute může obsahovat pouze písmena a číslice.',
     'array'                => ':attribute musí být pole.',
+    'backup_codes'         => 'Zadaný kód není platný nebo již byl použit.',
     'before'               => ':attribute musí být datum před :date.',
     'between'              => [
         'numeric' => ':attribute musí být hodnota mezi :min a :max.',
@@ -60,7 +61,7 @@ return [
         'array'   => ':attribute by měl obsahovat méně než :value položek.',
     ],
     'lte'                  => [
-        'numeric' => ':attribute musí být menší nebo rovno než :value.',
+        'numeric' => ':attribute musí být menší nebo rovno :value.',
         'file'    => 'Velikost souboru :attribute musí být menší než :value kB.',
         'string'  => ':attribute nesmí být delší než :value znaků.',
         'array'   => ':attribute by měl obsahovat maximálně :value položek.',
@@ -89,7 +90,7 @@ return [
     'required_without'     => ':attribute musí být vyplněno pokud :values není vyplněno.',
     'required_without_all' => ':attribute musí být vyplněno pokud není žádné z :values zvoleno.',
     'same'                 => ':attribute a :other se musí shodovat.',
-    'safe_url'             => 'The provided link may not be safe.',
+    'safe_url'             => 'Zadaný odkaz může být nebezpečný.',
     'size'                 => [
         'numeric' => ':attribute musí být přesně :size.',
         'file'    => ':attribute musí mít přesně :size Kilobytů.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute musí být řetězec znaků.',
     'timezone'             => ':attribute musí být platná časová zóna.',
+    'totp'                 => 'Zadaný kód je neplatný nebo vypršel.',
     'unique'               => ':attribute musí být unikátní.',
     'url'                  => 'Formát :attribute je neplatný.',
     'uploaded'             => 'Nahrávání :attribute se nezdařilo.',
index 16898bd2bf5e445cb025c1395b1e00987c5233ed..4d52f15fe9fa63c80af7fa32b915ea96c90fdba0 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'oprettede side',
-    'page_create_notification'    => 'Siden blev oprettet',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'opdaterede side',
-    'page_update_notification'    => 'Siden blev opdateret',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'slettede side',
-    'page_delete_notification'    => 'Siden blev slettet',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'gendannede side',
-    'page_restore_notification'   => 'Siden blev gendannet',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'flyttede side',
 
     // Chapters
     'chapter_create'              => 'oprettede kapitel',
-    'chapter_create_notification' => 'Kapitel blev oprettet',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'opdaterede kapitel',
-    'chapter_update_notification' => 'Kapitlet blev opdateret',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'slettede kapitel',
-    'chapter_delete_notification' => 'Kapitel blev slettet',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'flyttede kapitel',
 
     // Books
     'book_create'                 => 'oprettede bog',
-    'book_create_notification'    => 'Bogen blev oprettet',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'opdaterede bog',
-    'book_update_notification'    => 'Bogen blev opdateret',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'slettede bog',
-    'book_delete_notification'    => 'Bogen blev slettet',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorterede bogen',
-    'book_sort_notification'      => 'Bogen blev re-sorteret',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'oprettede bogreol',
-    'bookshelf_create_notification'    => 'Bogreolen blev oprettet',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'opdaterede bogreolen',
-    'bookshelf_update_notification'    => 'Bogreolen blev opdateret',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'slettede bogreol',
-    'bookshelf_delete_notification'    => 'Bogreolen blev opdateret',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" er blevet tilføjet til dine favoritter',
+    'favourite_remove_notification' => '":name" er blevet fjernet fra dine favoritter',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-faktor metode konfigureret',
+    'mfa_remove_method_notification' => 'Multi-faktor metode fjernet',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'kommenterede til',
index 8ea58517493187a77f01582a22590e2ec4aa2957..7ff3c68ffa28174cdc13030d8cfa1e68efb1e670 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Adgangskode',
     'password_confirm' => 'Bekræft adgangskode',
-    'password_hint' => 'Skal være på mindst 7 karakterer',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Glemt Adgangskode?',
     'remember_me' => 'Husk mig',
     'ldap_email_hint' => 'Angiv venligst din kontos e-mail.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'E-Mail domænet har ikke adgang til denne applikation',
     'register_success' => 'Tak for din registrering. Du er nu registeret og logget ind.',
 
-
     // 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.',
@@ -49,14 +48,13 @@ return [
     '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 E-Mail er blevet bekræftet!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Bekræftelsesmail sendt, tjek venligst din indboks.',
 
     'email_not_confirmed' => 'E-Mail adresse ikke bekræftet',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Velkommen til :appName!',
     'user_invite_page_text' => 'For at færdiggøre din konto og få adgang skal du indstille en adgangskode, der bruges til at logge ind på :appName ved fremtidige besøg.',
     'user_invite_page_confirm_button' => 'Bekræft adgangskode',
-    'user_invite_success' => 'Adgangskode indstillet, du har nu adgang til :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Opsætning af Multi-faktor godkendelse',
+    'mfa_setup_desc' => 'Opsæt multi-faktor godkendelse som et ekstra lag af sikkerhed for din brugerkonto.',
+    'mfa_setup_configured' => 'Allerede konfigureret',
+    'mfa_setup_reconfigure' => 'Genkonfigurer',
+    'mfa_setup_remove_confirmation' => 'Er du sikker på, at du vil fjerne denne multi-faktor godkendelsesmetode?',
+    'mfa_setup_action' => 'Opsætning',
+    'mfa_backup_codes_usage_limit_warning' => 'Du har mindre end 5 backup koder tilbage, generere og gem et nyt sæt før du løber tør for koder, for at forhindre at blive lukket ude af din konto.',
+    'mfa_option_totp_title' => 'Mobil app',
+    'mfa_option_totp_desc' => 'For at bruge multi-faktor godkendelse, skal du bruge en mobil app, der understøtter TOTP såsom Google Authenticator, Authy eller Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup koder',
+    'mfa_option_backup_codes_desc' => 'Gem sikkert et sæt af engangs backup koder, som du kan indtaste for at bekræfte din identitet.',
+    'mfa_gen_confirm_and_enable' => 'Bekræft og aktivér',
+    'mfa_gen_backup_codes_title' => 'Backup koder opsætning',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 8613cc04512544d15f4ad14be6e655f0652539ec..890bbe4b43fa66f2f348071f405f68085e7889b4 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rolle',
     'cover_image' => 'Coverbillede',
     'cover_image_description' => 'Dette billede skal være omtrent 440x250px.',
-    
+
     // Actions
     'actions' => 'Handlinger',
     'view' => 'Vis',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Nulstil',
     'remove' => 'Fjern',
     'add' => 'Tilføj',
+    'configure' => 'Konfigurer',
     'fullscreen' => 'Fuld skærm',
+    'favourite' => 'Foretrukken',
+    'unfavourite' => 'Fjern som foretrukken',
+    'next' => 'Næste',
+    'previous' => 'Forrige',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sorteringsindstillinger',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Ingen aktivitet at vise',
     'no_items' => 'Intet indhold tilgængeligt',
     'back_to_top' => 'Tilbage til toppen',
+    'skip_to_main_content' => 'Spring til indhold',
     'toggle_details' => 'Vis/skjul detaljer',
     'toggle_thumbnails' => 'Vis/skjul miniaturer',
     'details' => 'Detaljer',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Listevisning',
     'default' => 'Standard',
     'breadcrumb' => 'Brødkrumme',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Udvid header menu',
index adc723dada5e3e5a65e423a47fb458deb75f9af6..bea5ec91dbac34138b533cf066dcf60dbd8f9a68 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Billeder',
     'my_recent_drafts' => 'Mine seneste kladder',
     'my_recently_viewed' => 'Mine senest viste',
+    'my_most_viewed_favourites' => 'Mine mest viste favoritter',
+    'my_favourites' => 'Mine favoritter',
     'no_pages_viewed' => 'Du har ikke besøgt nogle sider',
     'no_pages_recently_created' => 'Ingen sider er blevet oprettet for nyligt',
     'no_pages_recently_updated' => 'Ingen sider er blevet opdateret for nyligt',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Indeholdt webfil',
     'export_pdf' => 'PDF-fil',
     'export_text' => 'Almindelig tekstfil',
+    'export_md' => 'Markdown Fil',
 
     // Permissions and restrictions
     'permissions' => 'Rettigheder',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Reoltilladelser',
     'shelves_permissions_updated' => 'Reoltilladelser opdateret',
     'shelves_permissions_active' => 'Aktive reoltilladelser',
+    'shelves_permissions_cascade_warning' => 'Tilladelser på reoler nedarves ikke automatisk til indeholdte bøger. Dette skyldes, at en bog kan eksistere på flere hylder. Tilladelser kan dog kopieres ned til underliggende bøger ved hjælp af muligheden, der findes nedenfor.',
     'shelves_copy_permissions_to_books' => 'Kopier tilladelser til bøger',
     'shelves_copy_permissions' => 'Kopier tilladelser',
     'shelves_copy_permissions_explain' => 'Dette vil anvende de aktuelle tilladelsesindstillinger på denne boghylde på alle bøger indeholdt i. Før aktivering skal du sikre dig, at ændringer i tilladelserne til denne boghylde er blevet gemt.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Kapitler sidst',
     'books_sort_show_other' => 'Vis andre bøger',
     'books_sort_save' => 'Gem ny ordre',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Kapitel',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Flyt kapitel',
     'chapters_move_named' => 'Flyt kapitel :chapterName',
     'chapter_move_success' => 'Kapitel flyttet til :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Kapiteltilladelser',
     'chapters_empty' => 'Der er lige nu ingen sider i dette kapitel.',
     'chapters_permissions_active' => 'Aktive kapiteltilladelser',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Ny side',
     'pages_editing_draft_notification' => 'Du redigerer en kladde der sidst var gemt :timeDiff.',
     'pages_draft_edited_notification' => 'Siden har været opdateret siden da. Det er anbefalet at du kasserer denne kladde.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count brugerer har begyndt at redigere denne side',
         'start_b' => ':userName er begyndt at redigere denne side',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Tilføj nogle tags for bedre at kategorisere dit indhold. \n Du kan tildele en værdi til et tag for mere dybdegående organisering.",
     'tags_add' => 'Tilføj endnu et tag',
     'tags_remove' => 'Fjern dette tag',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Vedhæftninger',
     'attachments_explain' => 'Upload nogle filer, eller vedhæft nogle links, der skal vises på siden. Disse er synlige i sidepanelet.',
     'attachments_explain_instant_save' => 'Ændringer her gemmes med det samme.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Er du sikker på at du vil slette denne revision?',
     'revision_restore_confirm' => 'Er du sikker på at du ønsker at gendanne denne revision? Nuværende sideindhold vil blive erstattet.',
     'revision_delete_success' => 'Revision slettet',
-    'revision_cannot_delete_latest' => 'Kan ikke slette seneste revision.'
+    'revision_cannot_delete_latest' => 'Kan ikke slette seneste revision.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 0f6287fbc1861a1546fa9ada4690b0bb3a2a43db..9e9cb808b2732dd5dea54e356f9113685d1823d7 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Kunne ikke finde en e-mail-adresse for denne bruger i de data, der leveres af det eksterne godkendelsessystem',
     'saml_invalid_response_id' => 'Anmodningen fra det eksterne godkendelsessystem genkendes ikke af en proces, der er startet af denne applikation. Navigering tilbage efter et login kan forårsage dette problem.',
     'saml_fail_authed' => 'Login ved hjælp af :system failed, systemet har ikke givet tilladelse',
+    'oidc_already_logged_in' => 'Allerede logget ind',
+    'oidc_user_not_registered' => 'Brugeren :name er ikke registreret, og automatisk registrering er slået fra',
+    'oidc_no_email_address' => 'Kunne ikke finde en e-mailadresse for denne bruger i de data, der leveres af det eksterne godkendelsessystem',
+    'oidc_fail_authed' => 'Login ved hjælp af :system fejlede, systemet har ikke givet tilladelse',
     'social_no_action_defined' => 'Ingen handling er defineret',
     'social_login_bad_response' => "Der opstod en fejl under :socialAccount login:\n:error",
     'social_account_in_use' => 'Denne :socialAccount konto er allerede i brug, prøv at logge ind med :socialAccount loginmetoden.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Siden blev ikke fundet',
     'sorry_page_not_found' => 'Beklager, siden du leder efter blev ikke fundet.',
     'sorry_page_not_found_permission_warning' => 'Hvis du forventede, at denne side skulle eksistere, har du muligvis ikke tilladelse til at se den.',
+    'image_not_found' => 'Billede ikke fundet',
+    'image_not_found_subtitle' => 'Beklager, billedet du ledte efter kunne ikke findes.',
+    'image_not_found_details' => 'Hvis du forventede, at dette billede skulle eksistere, kan det være blevet slettet.',
     'return_home' => 'Gå tilbage til hjem',
     'error_occurred' => 'Der opstod en fejl',
     'app_down' => ':appName er nede lige nu',
index e0640888b00b78dfa4a5169886e75fe8770a47de..78491e621826a5978cc5c99d66ca768bdc16a5ef 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Vedligeholdelse',
     'maint_image_cleanup' => 'Ryd op i billeder',
-    'maint_image_cleanup_desc' => "Scanner side & revisionsindhold for at kontrollere, hvilke billeder og tegninger, der i øjeblikket er i brug, og hvilke billeder, der er overflødige. Sørg for, at du opretter en komplet database og billedbackup, før du kører dette.",
+    'maint_image_cleanup_desc' => 'Scanner side & revisionsindhold for at kontrollere, hvilke billeder og tegninger, der i øjeblikket er i brug, og hvilke billeder, der er overflødige. Sørg for, at du opretter en komplet database og billedbackup, før du kører dette.',
     'maint_delete_images_only_in_revisions' => 'Slet også billeder, der kun findes i gamle siderevisioner',
     'maint_image_cleanup_run' => 'Kør Oprydning',
     'maint_image_cleanup_warning' => 'der blev fundet :count potentielt ubrugte billeder. Er du sikker på, at du vil slette disse billeder?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Papirkurv',
     'recycle_bin_desc' => 'Her kan du gendanne elementer, der er blevet slettet eller vælge at permanent fjerne dem fra systemet. Denne liste er ufiltreret, i modsætning til lignende aktivitetslister i systemet, hvor tilladelsesfiltre anvendes.',
     'recycle_bin_deleted_item' => 'Slettet element',
+    'recycle_bin_deleted_parent' => 'Overordnet',
     'recycle_bin_deleted_by' => 'Slettet af',
     'recycle_bin_deleted_at' => 'Sletningstidspunkt',
     'recycle_bin_permanently_delete' => 'Slet permanent',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elementer der skal gendannes',
     'recycle_bin_restore_confirm' => 'Denne handling vil gendanne det slettede element, herunder alle underelementer, til deres oprindelige placering. Hvis den oprindelige placering siden er blevet slettet, og nu er i papirkurven, vil det overordnede element også skulle gendannes.',
     'recycle_bin_restore_deleted_parent' => 'Det overordnede element til dette element er også blevet slettet. Disse vil forblive slettet indtil det overordnede også er gendannet.',
+    'recycle_bin_restore_parent' => 'Gendan Overordnet',
     'recycle_bin_destroy_notification' => 'Slettede :count elementer fra papirkurven.',
     'recycle_bin_restore_notification' => 'Gendannede :count elementer fra papirkurven.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Bruger',
     'audit_table_event' => 'Hændelse',
     'audit_table_related' => 'Relateret element eller detalje',
+    'audit_table_ip' => 'IP-adresse',
     'audit_table_date' => 'Aktivitetsdato',
     'audit_date_from' => 'Datointerval fra',
     'audit_date_to' => 'Datointerval til',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Rolledetaljer',
     'role_name' => 'Rollenavn',
     'role_desc' => 'Kort beskrivelse af rolle',
+    'role_mfa_enforced' => 'Kræver multifaktor godkendelse',
     'role_external_auth_id' => 'Eksterne godkendelses-IDer',
     'role_system' => 'Systemtilladelser',
     'role_manage_users' => 'Administrere brugere',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Administrer side-skabeloner',
     'role_access_api' => 'Tilgå system-API',
     'role_manage_settings' => 'Administrer app-indstillinger',
+    'role_export_content' => 'Eksporter indhold',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Brugerroller',
     'users_role_desc' => 'Vælg hvilke roller denne bruger skal tildeles. Hvis en bruger er tildelt flere roller, sammenføres tilladelserne fra disse roller, og de får alle evnerne fra de tildelte roller.',
     'users_password' => 'Brugeradgangskode',
-    'users_password_desc' => 'Sæt et kodeord, der bruges til at logge på applikationen. Dette skal være mindst 6 tegn langt.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Du kan vælge at sende denne bruger en invitation på E-Mail, som giver dem mulighed for at indstille deres egen adgangskode, ellers kan du indstille deres adgangskode selv.',
     'users_send_invite_option' => 'Send bruger en invitationsmail',
     'users_external_auth_id' => 'Ekstern godkendelses ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Opret Token',
     'users_api_tokens_expires' => 'Udløber',
     'users_api_tokens_docs' => 'API-dokumentation',
+    'users_mfa' => 'Multi-faktor godkendelse',
+    'users_mfa_desc' => 'Opsæt multi-faktor godkendelse som et ekstra lag af sikkerhed for din brugerkonto.',
+    'users_mfa_x_methods' => ':count metode konfigureret|:count metoder konfigureret',
+    'users_mfa_configure' => 'Konfigurer metoder',
 
     // API Tokens
     'user_api_token_create' => 'Opret API-token',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Er du sikker på, at du vil slette denne API-token?',
     'user_api_token_delete_success' => 'API-token slettet',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'Hebraisk',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 6c11f2e0fe4f3d938cd6b87a2903a6c3ec31c173..c54b07a6eb529ced79a9d26bbde3ffc8efbbe921 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute må kun bestå af bogstaver, tal, binde- og under-streger.',
     'alpha_num'            => ':attribute må kun indeholde bogstaver og tal.',
     'array'                => ':attribute skal være et array.',
+    'backup_codes'         => 'Den angivne kode er ikke gyldig eller er allerede brugt.',
     'before'               => ':attribute skal være en dato før :date.',
     'between'              => [
         'numeric' => ':attribute skal være mellem :min og :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute skal være tekst.',
     'timezone'             => ':attribute skal være en gyldig zone.',
+    'totp'                 => 'Den angivne kode er ikke gyldig eller er udløbet.',
     'unique'               => ':attribute er allerede i brug.',
     'url'                  => ':attribute-formatet er ugyldigt.',
     'uploaded'             => 'Filen kunne ikke oploades. Serveren accepterer muligvis ikke filer af denne størrelse.',
index 562a9add365decee4f8d5fe06b37dcd91f35dd0c..d9b63efbefdc73d520e8879e51c32dc3b6e965a9 100644 (file)
@@ -36,13 +36,29 @@ return [
     'book_sort_notification'      => 'Das Buch wurde erfolgreich umsortiert',
 
     // Bookshelves
-    'bookshelf_create'            => 'hat das Bücherregal erstellt',
+    'bookshelf_create'            => 'erstelltes Bücherregal',
     'bookshelf_create_notification'    => 'Das Bücherregal wurde erfolgreich erstellt',
     'bookshelf_update'                 => 'hat das Bücherregal geändert',
     'bookshelf_update_notification'    => 'Das Bücherregal wurde erfolgreich geändert',
     'bookshelf_delete'                 => 'hat das Bücherregal gelöscht',
     'bookshelf_delete_notification'    => 'Das Bücherregal wurde erfolgreich gelöscht',
 
+    // Favourites
+    'favourite_add_notification' => '":name" wurde zu deinen Favoriten hinzugefügt',
+    'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-Faktor-Methode erfolgreich konfiguriert',
+    'mfa_remove_method_notification' => 'Multi-Faktor-Methode erfolgreich entfernt',
+
+    // Webhooks
+    'webhook_create' => 'erstellter Webhook',
+    'webhook_create_notification' => 'Webhook wurde erfolgreich eingerichtet',
+    'webhook_update' => 'aktualisierter Webhook',
+    'webhook_update_notification' => 'Webhook wurde erfolgreich aktualisiert',
+    'webhook_delete' => 'gelöschter Webhook',
+    'webhook_delete_notification' => 'Webhook wurde erfolgreich gelöscht',
+
     // Other
     'commented_on'                => 'hat einen Kommentar hinzugefügt',
     'permissions_update'          => 'hat die Berechtigungen aktualisiert',
index 1f5a49cbddc240b36b70ff02fec035e6355abdf3..44c2887aadc3ae9abb5eeca4edc1be3e496cdba9 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'failed' => 'Die eingegebenen Anmeldedaten sind ungültig.',
+    'failed' => 'Diese Anmeldedaten stimmen nicht mit unseren Aufzeichnungen überein.',
     'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.',
 
     // Login & Register
@@ -20,8 +20,8 @@ return [
     'username' => 'Benutzername',
     'email' => 'E-Mail',
     'password' => 'Passwort',
-    'password_confirm' => 'Passwort best&auml;tigen',
-    'password_hint' => 'Mindestlänge: 7 Zeichen',
+    'password_confirm' => 'Passwort bestätigen',
+    'password_hint' => 'Muss mindestens 8 Zeichen lang sein',
     'forgot_password' => 'Passwort vergessen?',
     'remember_me' => 'Angemeldet bleiben',
     'ldap_email_hint' => 'Bitte geben Sie eine E-Mail-Adresse ein, um diese mit dem Account zu nutzen.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Sie können sich mit dieser E-Mail nicht registrieren.',
     'register_success' => 'Vielen Dank für Ihre Registrierung! Die Daten sind gespeichert und Sie sind angemeldet.',
 
-
     // Password Reset
     'reset_password' => 'Passwort vergessen',
     'reset_password_send_instructions' => 'Bitte geben Sie Ihre E-Mail-Adresse ein. Danach erhalten Sie eine E-Mail mit einem Link zum Zurücksetzen Ihres Passwortes.',
@@ -47,8 +46,7 @@ return [
     'reset_password_success' => 'Ihr Passwort wurde erfolgreich zurückgesetzt.',
     'email_reset_subject' => 'Passwort zurücksetzen für :appName',
     'email_reset_text' => 'Sie erhalten diese E-Mail, weil jemand versucht hat, Ihr Passwort zurückzusetzen.',
-    'email_reset_not_requested' => 'Wenn Sie das nicht waren, brauchen Sie nichts weiter zu tun.',
-
+    'email_reset_not_requested' => 'Wenn Sie das Zurücksetzen des Passworts nicht angefordert haben, ist keine weitere Aktion erforderlich.',
 
     // Email Confirmation
     'email_confirm_subject' => 'Bestätigen Sie Ihre E-Mail-Adresse für :appName',
@@ -56,7 +54,7 @@ return [
     'email_confirm_text' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf die Schaltfläche klicken:',
     'email_confirm_action' => 'E-Mail-Adresse bestätigen',
     'email_confirm_send_error' => 'Leider konnte die für die Registrierung notwendige E-Mail zur bestätigung Ihrer E-Mail-Adresse nicht versandt werden. Bitte kontaktieren Sie den Systemadministrator!',
-    'email_confirm_success' => 'Ihre E-Mail-Adresse wurde best&auml;tigt!',
+    'email_confirm_success' => 'Ihre E-Mail wurde bestätigt! Sie sollten nun in der Lage sein, sich mit dieser E-Mail-Adresse anzumelden.',
     'email_confirm_resent' => 'Bestätigungs-E-Mail wurde erneut versendet, bitte überprüfen Sie Ihren Posteingang.',
 
     'email_not_confirmed' => 'E-Mail-Adresse ist nicht bestätigt',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Willkommen bei :appName!',
     'user_invite_page_text' => 'Um die Anmeldung abzuschließen und Zugriff auf :appName zu bekommen muss noch ein Passwort festgelegt werden. Dieses wird in Zukunft zum Einloggen benötigt.',
     'user_invite_page_confirm_button' => 'Passwort wiederholen',
-    'user_invite_success' => 'Passwort gesetzt, Sie haben nun Zugriff auf :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Passwort gesetzt, Sie sollten nun in der Lage sein, sich mit Ihrem Passwort an :appName anzumelden!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Multi-Faktor-Authentifizierung einrichten',
+    'mfa_setup_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.',
+    'mfa_setup_configured' => 'Bereits konfiguriert',
+    'mfa_setup_reconfigure' => 'Umkonfigurieren',
+    'mfa_setup_remove_confirmation' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode entfernen möchten?',
+    'mfa_setup_action' => 'Einrichtung',
+    'mfa_backup_codes_usage_limit_warning' => 'Sie haben weniger als 5 Backup-Codes übrig, Bitte erstellen und speichern Sie ein neues Set bevor Sie keine Codes mehr haben, um zu verhindern, dass Sie von Ihrem Konto gesperrt werden.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Code',
+    'mfa_option_backup_codes_desc' => 'Speichern Sie sicher eine Reihe von einmaligen Backup-Codes, die Sie eingeben können, um Ihre Identität zu überprüfen.',
+    'mfa_gen_confirm_and_enable' => 'Bestätigen und aktivieren',
+    'mfa_gen_backup_codes_title' => 'Backup-Codes einrichten',
+    'mfa_gen_backup_codes_desc' => 'Speichern Sie die folgende Liste der Codes an einem sicheren Ort. Wenn Sie auf das System zugreifen, können Sie einen der Codes als zweiten Authentifizierungsmechanismus verwenden.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Jeder Code kann nur einmal verwendet werden',
+    'mfa_gen_totp_title' => 'Mobile App einrichten',
+    'mfa_gen_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scannen Sie den QR-Code unten mit Ihrer bevorzugten Authentifizierungs-App, um loszulegen.',
+    'mfa_gen_totp_verify_setup' => 'Setup überprüfen',
+    'mfa_gen_totp_verify_setup_desc' => 'Überprüfen Sie, dass alles funktioniert, indem Sie einen Code in Ihrer Authentifizierungs-App in das Eingabefeld unten eingeben:',
+    'mfa_gen_totp_provide_code_here' => 'Geben Sie hier Ihre App generierten Code ein',
+    'mfa_verify_access' => 'Zugriff überprüfen',
+    'mfa_verify_access_desc' => 'Ihr Benutzerkonto erfordert, dass Sie Ihre Identität über eine zusätzliche Verifikationsebene bestätigen, bevor Sie den Zugriff gewähren. Überprüfen Sie mit einer Ihrer konfigurierten Methoden, um fortzufahren.',
+    'mfa_verify_no_methods' => 'Keine Methoden konfiguriert',
+    'mfa_verify_no_methods_desc' => 'Es konnten keine Mehrfach-Faktor-Authentifizierungsmethoden für Ihr Konto gefunden werden. Sie müssen mindestens eine Methode einrichten, bevor Sie Zugriff erhalten.',
+    'mfa_verify_use_totp' => 'Mit einer mobilen App verifizieren',
+    'mfa_verify_use_backup_codes' => 'Mit einem Backup-Code überprüfen',
+    'mfa_verify_backup_code' => 'Backup-Code',
+    'mfa_verify_backup_code_desc' => 'Geben Sie einen Ihrer verbleibenden Backup-Codes unten ein:',
+    'mfa_verify_backup_code_enter_here' => 'Backup-Code hier eingeben',
+    'mfa_verify_totp_desc' => 'Geben Sie den Code ein, der mit Ihrer mobilen App generiert wurde:',
+    'mfa_setup_login_notification' => 'Multi-Faktor-Methode konfiguriert. Bitte melden Sie sich jetzt erneut mit der konfigurierten Methode an.',
+];
index fb9e91e438134e4a95ffdb3449bf5eb0e0b43b05..20688ef35a6add9c60bd724c3db5c9176f684cbe 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rolle',
     'cover_image' => 'Titelbild',
     'cover_image_description' => 'Das Bild sollte eine Auflösung von 440x250px haben.',
-    
+
     // Actions
     'actions' => 'Aktionen',
     'view' => 'Anzeigen',
@@ -33,13 +33,20 @@ return [
     'copy' => 'Kopieren',
     'reply' => 'Antworten',
     'delete' => 'Löschen',
-    'delete_confirm' => 'Löschen Bestätigen',
+    'delete_confirm' => 'Löschen bestätigen',
     'search' => 'Suchen',
     'search_clear' => 'Suche löschen',
     'reset' => 'Zurücksetzen',
     'remove' => 'Entfernen',
     'add' => 'Hinzufügen',
+    'configure' => 'Konfigurieren',
     'fullscreen' => 'Vollbild',
+    'favourite' => 'Favoriten',
+    'unfavourite' => 'Kein Favorit',
+    'next' => 'Nächste',
+    'previous' => 'Vorheriges',
+    'filter_active' => 'Gesetzte Filter:',
+    'filter_clear' => 'Filter löschen',
 
     // Sort Options
     'sort_options' => 'Sortieroptionen',
@@ -52,10 +59,11 @@ return [
     'sort_updated_at' => 'Aktualisierungsdatum',
 
     // Misc
-    'deleted_user' => 'Gelöschte Benutzer',
+    'deleted_user' => 'Gelöschter Benutzer',
     'no_activity' => 'Keine Aktivitäten zum Anzeigen',
-    'no_items' => 'Keine Einträge gefunden.',
+    'no_items' => 'Keine Einträge gefunden',
     'back_to_top' => 'nach oben',
+    'skip_to_main_content' => 'Direkt zum Hauptinhalt',
     'toggle_details' => 'Details zeigen/verstecken',
     'toggle_thumbnails' => 'Thumbnails zeigen/verstecken',
     'details' => 'Details',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Listenansicht',
     'default' => 'Voreinstellung',
     'breadcrumb' => 'Brotkrumen',
+    'status' => 'Status',
+    'status_active' => 'Aktiv',
+    'status_inactive' => 'Inaktiv',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Header-Menü erweitern',
index 22319d30da94a92e6344a4bd6f21052a29817271..2d8b66ed4d11df1012d920518d79b29bb8dece6c 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Bilder',
     'my_recent_drafts' => 'Meine kürzlichen Entwürfe',
     'my_recently_viewed' => 'Kürzlich von mir angesehen',
+    'my_most_viewed_favourites' => 'Meine meistgesehenen Favoriten',
+    'my_favourites' => 'Meine Favoriten',
     'no_pages_viewed' => 'Sie haben bisher keine Seiten angesehen',
     'no_pages_recently_created' => 'Sie haben bisher keine Seiten angelegt',
     'no_pages_recently_updated' => 'Sie haben bisher keine Seiten aktualisiert',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'HTML-Datei',
     'export_pdf' => 'PDF-Datei',
     'export_text' => 'Textdatei',
+    'export_md' => 'Markdown-Datei',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Regal-Berechtigungen',
     'shelves_permissions_updated' => 'Regal-Berechtigungen aktualisiert',
     'shelves_permissions_active' => 'Regal-Berechtigungen aktiv',
+    'shelves_permissions_cascade_warning' => 'Die Berechtigungen in Bücherregalen werden nicht automatisch auf enthaltene Bücher kaskadiert, weil ein Buch in mehreren Regalen existieren kann. Berechtigungen können jedoch mit der unten stehenden Option in untergeordnete Bücher kopiert werden.',
     'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zum Buch',
     'shelves_copy_permissions' => 'Berechtigungen kopieren',
     'shelves_copy_permissions_explain' => 'Hiermit werden die Berechtigungen des aktuellen Regals auf alle enthaltenen Bücher übertragen. Überprüfen Sie vor der Aktivierung, ob alle Berechtigungsänderungen am aktuellen Regal gespeichert wurden.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Kapitel zuletzt',
     'books_sort_show_other' => 'Andere Bücher anzeigen',
     'books_sort_save' => 'Neue Reihenfolge speichern',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Kapitel',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Kapitel verschieben',
     'chapters_move_named' => 'Kapitel ":chapterName" verschieben',
     'chapter_move_success' => 'Das Kapitel wurde in das Buch ":bookName" verschoben.',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Kapitel-Berechtigungen',
     'chapters_empty' => 'Aktuell sind keine Kapitel diesem Buch hinzugefügt worden.',
     'chapters_permissions_active' => 'Kapitel-Berechtigungen aktiv',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Neue Seite',
     'pages_editing_draft_notification' => 'Sie bearbeiten momenten einen Entwurf, der zuletzt :timeDiff gespeichert wurde.',
     'pages_draft_edited_notification' => 'Diese Seite wurde seit diesem Zeitpunkt verändert. Wir empfehlen Ihnen, diesen Entwurf zu verwerfen.',
+    'pages_draft_page_changed_since_creation' => 'Diese Seite wurde seit der Erstellung dieses Entwurfs aktualisiert. Es wird empfohlen, diesen Entwurf zu verwerfen oder darauf zu achten, dass keine Seitenänderungen überschrieben werden.',
     'pages_draft_edit_active' => [
         'start_a' => ':count Benutzer bearbeiten derzeit diese Seite.',
         'start_b' => ':userName bearbeitet jetzt diese Seite.',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Fügen Sie Schlagwörter hinzu, um Ihren Inhalt zu kategorisieren.\nSie können einen erklärenden Inhalt hinzufügen, um eine genauere Unterteilung vorzunehmen.",
     'tags_add' => 'Weiteres Schlagwort hinzufügen',
     'tags_remove' => 'Diesen Tag entfernen',
+    'tags_usages' => 'Gesamte Tagnutzung',
+    'tags_assigned_pages' => 'Zugewiesen zu Seiten',
+    'tags_assigned_chapters' => 'Zugewiesen zu Kapiteln',
+    'tags_assigned_books' => 'Zugewiesen zu Büchern',
+    'tags_assigned_shelves' => 'Zugewiesen zu Regalen',
+    'tags_x_unique_values' => ':count eindeutige Werte',
+    'tags_all_values' => 'Alle Werte',
+    'tags_view_tags' => 'Tags anzeigen',
+    'tags_view_existing_tags' => 'Vorhandene Tags anzeigen',
+    'tags_list_empty_hint' => 'Tags können über die Seitenleiste des Seiteneditors oder beim Bearbeiten der Details eines Buches, Kapitels oder Regals zugewiesen werden.',
     'attachments' => 'Anhänge',
     'attachments_explain' => 'Sie können auf Ihrer Seite Dateien hochladen oder Links hinzufügen. Diese werden in der Seitenleiste angezeigt.',
     'attachments_explain_instant_save' => 'Änderungen werden direkt gespeichert.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Sind Sie sicher, dass Sie diese Revision löschen wollen?',
     'revision_restore_confirm' => 'Sind Sie sicher, dass Sie diese Revision wiederherstellen wollen? Der aktuelle Seiteninhalt wird ersetzt.',
     'revision_delete_success' => 'Revision gelöscht',
-    'revision_cannot_delete_latest' => 'Die letzte Version kann nicht gelöscht werden.'
+    'revision_cannot_delete_latest' => 'Die letzte Version kann nicht gelöscht werden.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 32be9b248f5933fe8942701363d12b3f15249a78..cedd85ad7dd212e0ab18864ced9e0ecebab2d4d8 100644 (file)
@@ -5,7 +5,7 @@
 return [
 
     // Permissions
-    'permission' => 'Sie haben keine Berechtigung, auf diese Seite zuzugreifen.',
+    'permission' => 'Sie haben keine Zugriffsberechtigung auf die angeforderte Seite.',
     'permissionJson' => 'Sie haben keine Berechtigung, die angeforderte Aktion auszuführen.',
 
     // Auth
@@ -14,7 +14,7 @@ return [
     'email_confirmation_invalid' => 'Der Bestätigungslink ist nicht gültig oder wurde bereits verwendet. Bitte registrieren Sie sich erneut.',
     'email_confirmation_expired' => 'Der Bestätigungslink ist abgelaufen. Es wurde eine neue Bestätigungs-E-Mail gesendet.',
     'email_confirmation_awaiting' => 'Die E-Mail-Adresse für das verwendete Konto muss bestätigt werden',
-    'ldap_fail_anonymous' => 'Anonymer LDAP-Zugriff ist fehlgeschlafgen',
+    'ldap_fail_anonymous' => 'Anonymer LDAP-Zugriff ist fehlgeschlagen',
     'ldap_fail_authed' => 'LDAP-Zugriff mit DN und Passwort ist fehlgeschlagen',
     'ldap_extension_not_installed' => 'LDAP-PHP-Erweiterung ist nicht installiert.',
     'ldap_cannot_connect' => 'Die Verbindung zum LDAP-Server ist fehlgeschlagen. Beim initialen Verbindungsaufbau trat ein Fehler auf.',
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden',
     'saml_invalid_response_id' => 'Die Anfrage vom externen Authentifizierungssystem wird von einem von dieser Anwendung gestarteten Prozess nicht erkannt. Das Zurückgehen nach einem Login könnte dieses Problem verursachen.',
     'saml_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen',
+    'oidc_already_logged_in' => 'Bereits angemeldet',
+    'oidc_user_not_registered' => 'Der Benutzer :name ist nicht registriert und die automatische Registrierung ist deaktiviert',
+    'oidc_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden',
+    'oidc_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen',
     'social_no_action_defined' => 'Es ist keine Aktion definiert',
     'social_login_bad_response' => "Fehler bei der :socialAccount-Anmeldung: \n:error",
     'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melden Sie sich mit dem :socialAccount-Konto an.',
@@ -43,14 +47,14 @@ return [
     'uploaded'  => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.',
     'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.',
     'image_upload_type_error' => 'Der Bildtyp der hochgeladenen Datei ist ungültig.',
-    'file_upload_timeout' => 'Der Upload der Datei ist abgelaufen.',
+    'file_upload_timeout' => 'Der Datei-Upload hat das Zeitlimit überschritten.',
 
     // Attachments
     'attachment_not_found' => 'Anhang konnte nicht gefunden werden.',
 
     // Pages
     'page_draft_autosave_fail' => 'Fehler beim Speichern des Entwurfs. Stellen Sie sicher, dass Sie mit dem Internet verbunden sind, bevor Sie den Entwurf dieser Seite speichern.',
-    'page_custom_home_deletion' => 'Eine als Startseite gesetzte Seite kann nicht gelöscht werden.',
+    'page_custom_home_deletion' => 'Eine als Startseite gesetzte Seite kann nicht gelöscht werden',
 
     // Entities
     'entity_not_found' => 'Eintrag nicht gefunden',
@@ -58,45 +62,48 @@ return [
     'book_not_found' => 'Buch nicht gefunden',
     'page_not_found' => 'Seite nicht gefunden',
     'chapter_not_found' => 'Kapitel nicht gefunden',
-    'selected_book_not_found' => 'Das gewählte Buch wurde nicht gefunden.',
+    'selected_book_not_found' => 'Das gewählte Buch wurde nicht gefunden',
     'selected_book_chapter_not_found' => 'Das gewählte Buch oder Kapitel wurde nicht gefunden.',
     'guests_cannot_save_drafts' => 'Gäste können keine Entwürfe speichern',
 
     // Users
-    'users_cannot_delete_only_admin' => 'Sie können den einzigen Administrator nicht löschen.',
+    'users_cannot_delete_only_admin' => 'Sie können den einzigen Administrator nicht löschen',
     'users_cannot_delete_guest' => 'Sie können den Gast-Benutzer nicht löschen',
 
     // Roles
-    'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden.',
+    'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden',
     'role_system_cannot_be_deleted' => 'Dies ist eine Systemrolle und kann nicht gelöscht werden',
     'role_registration_default_cannot_delete' => 'Diese Rolle kann nicht gelöscht werden, solange sie als Standardrolle für neue Registrierungen gesetzt ist',
-    'role_cannot_remove_only_admin' => 'Dieser Benutzer ist der einzige Benutzer, welchem die Administratorrolle zugeordnet ist. Ordnen Sie die Administratorrolle einem anderen Benutzer zu, bevor Sie versuchen, sie hier zu entfernen.',
+    'role_cannot_remove_only_admin' => 'Dieser Benutzer ist der einzige Benutzer, welchem die Administratorrolle zugeordnet ist. Ordnen Sie die Administratorrolle einem anderen Benutzer zu bevor Sie versuchen sie hier zu entfernen.',
 
     // Comments
     'comment_list' => 'Beim Abrufen der Kommentare ist ein Fehler aufgetreten.',
     'cannot_add_comment_to_draft' => 'Du kannst keine Kommentare zu einem Entwurf hinzufügen.',
     'comment_add' => 'Beim Hinzufügen des Kommentars ist ein Fehler aufgetreten.',
     'comment_delete' => 'Beim Löschen des Kommentars ist ein Fehler aufgetreten.',
-    'empty_comment' => 'Kann keinen leeren Kommentar hinzufügen',
+    'empty_comment' => 'Kann keinen leeren Kommentar hinzufügen.',
 
     // Error pages
     '404_page_not_found' => 'Seite nicht gefunden',
-    'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Sie angefordert haben, wurde nicht gefunden.',
+    'sorry_page_not_found' => 'Entschuldigung. Die angeforderte Seite wurde nicht gefunden.',
     'sorry_page_not_found_permission_warning' => 'Wenn Sie erwartet haben, dass diese Seite existiert, haben Sie möglicherweise nicht die Berechtigung, sie anzuzeigen.',
+    'image_not_found' => 'Bild nicht gefunden',
+    'image_not_found_subtitle' => 'Entschuldigung. Das angeforderte Bild wurde nicht gefunden.',
+    'image_not_found_details' => 'Wenn Sie erwartet haben, dass dieses Bild existiert, könnte es gelöscht worden sein.',
     'return_home' => 'Zurück zur Startseite',
     'error_occurred' => 'Es ist ein Fehler aufgetreten',
-    'app_down' => ':appName befindet sich aktuell im Wartungsmodus.',
+    'app_down' => ':appName befindet sich aktuell im Wartungsmodus',
     'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.',
 
     // API errors
-    'api_no_authorization_found' => 'Kein Autorisierungs-Token für die Anfrage gefunden',
-    'api_bad_authorization_format' => 'Ein Autorisierungs-Token wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein',
-    'api_user_token_not_found' => 'Es wurde kein passender API-Token für den angegebenen Autorisierungs-Token gefunden',
-    'api_incorrect_token_secret' => 'Das für den angegebenen API-Token angegebene Kennwort ist falsch',
-    'api_user_no_api_permission' => 'Der Besitzer des verwendeten API-Token hat keine Berechtigung für API-Aufrufe',
-    'api_user_token_expired' => 'Das verwendete Autorisierungs-Token ist abgelaufen',
+    'api_no_authorization_found' => 'Kein Autorisierungstoken für die Anfrage gefunden',
+    'api_bad_authorization_format' => 'Ein Autorisierungstoken wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein',
+    'api_user_token_not_found' => 'Es wurde kein passender API-Token für den angegebenen Autorisierungstoken gefunden',
+    'api_incorrect_token_secret' => 'Das Kennwort für das angegebene API-Token ist falsch',
+    'api_user_no_api_permission' => 'Der Besitzer des verwendeten API-Tokens hat keine Berechtigung für API-Aufrufe',
+    'api_user_token_expired' => 'Das verwendete Autorisierungstoken ist abgelaufen',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Fehler beim Senden einer Test E-Mail:',
+    'maintenance_test_email_failure' => 'Fehler beim Versenden einer Test E-Mail:',
 
 ];
index 3da092cb8c7e234a0f5a852afb272e183a5999d9..5896297e08bf6b33459b55407674f726e6d7b8dc 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'password' => 'Passwörter müssen aus mindestens sechs Zeichen bestehen und mit der eingegebenen Wiederholung übereinstimmen.',
+    'password' => 'Passwörter müssen aus mindestens acht Zeichen bestehen und mit der eingegebenen Wiederholung übereinstimmen.',
     'user' => "Es wurde kein Benutzer mit dieser E-Mail-Adresse gefunden.",
     'token' => 'Der Link zum Zurücksetzen Ihres Passworts ist entweder ungültig oder abgelaufen.',
     'sent' => 'Der Link zum Zurücksetzen Ihres Passwortes wurde Ihnen per E-Mail zugesendet.',
index 71276c034dfbbd833db16d63593f2ebac113450a..c375e289b736bd242f29243493bec9eda3743784 100644 (file)
@@ -18,7 +18,7 @@ return [
     'app_name_desc' => 'Dieser Name wird im Header und in E-Mails angezeigt.',
     'app_name_header' => 'Anwendungsname im Header anzeigen?',
     'app_public_access' => 'Öffentlicher Zugriff',
-    'app_public_access_desc' => 'Wenn Sie diese Option aktivieren, können Besucher, die nicht angemeldet sind, auf Inhalte in Ihrer BookStack-Instanz zugreifen.',
+    'app_public_access_desc' => 'Wenn Sie diese Option aktivieren können Besucher, die nicht angemeldet sind, auf Inhalte in Ihrer BookStack-Instanz zugreifen.',
     'app_public_access_desc_guest' => 'Der Zugang für öffentliche Besucher kann über den Benutzer "Guest" gesteuert werden.',
     'app_public_access_toggle' => 'Öffentlichen Zugriff erlauben',
     'app_public_viewing' => 'Öffentliche Ansicht erlauben?',
@@ -40,7 +40,7 @@ Wenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt
     'app_homepage_desc' => 'Wählen Sie eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.',
     'app_homepage_select' => 'Wählen Sie eine Seite aus',
     'app_footer_links' => 'Fußzeilen-Links',
-    'app_footer_links_desc' => 'Fügen Sie Links hinzu, die innerhalb der Seitenfußzeile angezeigt werden. Diese werden am unteren Ende der meisten Seiten angezeigt, einschließlich derjenigen, die keinen Login benötigen. Sie können die Bezeichnung "trans::<key>" verwenden, um systemdefinierte Übersetzungen zu verwenden. Beispiel: Mit "trans::common.privacy_policy" wird der übersetzte Text "Privacy Policy" bereitgestellt, und "trans::common.terms_of_service" liefert den übersetzten Text "Terms of Service".',
+    'app_footer_links_desc' => 'Fügen Sie Links hinzu, die innerhalb der Seitenfußzeile angezeigt werden. Diese werden am unteren Ende der meisten Seiten angezeigt, einschließlich derjenigen, die keinen Login benötigen. Sie können die Bezeichnung "trans::<key>" verwenden, um systemdefinierte Übersetzungen zu verwenden. Beispiel: Mit "trans::common.privacy_policy" wird der übersetzte Text "Privacy Policy" bereitgestellt und "trans::common.terms_of_service" liefert den übersetzten Text "Terms of Service".',
     'app_footer_links_label' => 'Link-Label',
     'app_footer_links_url' => 'Link-URL',
     'app_footer_links_add' => 'Fußzeilen-Link hinzufügen',
@@ -59,7 +59,7 @@ Wenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt
 
     // Registration Settings
     'reg_settings' => 'Registrierungseinstellungen',
-    'reg_enable' => 'Registrierung erlauben?',
+    'reg_enable' => 'Registrierung erlauben',
     'reg_enable_toggle' => 'Registrierung erlauben',
     'reg_enable_desc' => 'Wenn die Registrierung erlaubt ist, kann sich der Benutzer als Anwendungsbenutzer anmelden. Bei der Registrierung erhält er eine einzige, voreingestellte Benutzerrolle.',
     'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
@@ -75,7 +75,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     // Maintenance settings
     'maint' => 'Wartung',
     'maint_image_cleanup' => 'Bilder bereinigen',
-    'maint_image_cleanup_desc' => "Überprüft Seiten- und Versionsinhalte auf ungenutzte und mehrfach vorhandene Bilder. Erstellen Sie vor dem Start ein Backup Ihrer Datenbank und Bilder.",
+    'maint_image_cleanup_desc' => 'Überprüft Seiten- und Versionsinhalte auf ungenutzte und mehrfach vorhandene Bilder. Erstellen Sie vor dem Start ein Backup Ihrer Datenbank und Bilder.',
     'maint_delete_images_only_in_revisions' => 'Lösche auch Bilder, die nur in alten Seitenüberarbeitungen vorhanden sind',
     'maint_image_cleanup_run' => 'Reinigung starten',
     'maint_image_cleanup_warning' => ':count eventuell unbenutze Bilder wurden gefunden. Möchten Sie diese Bilder löschen?',
@@ -95,6 +95,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'recycle_bin' => 'Papierkorb',
     'recycle_bin_desc' => 'Hier können Sie gelöschte Elemente wiederherstellen oder sie dauerhaft aus dem System entfernen. Diese Liste ist nicht gefiltert, im Gegensatz zu ähnlichen Aktivitätslisten im System, wo Berechtigungsfilter angewendet werden.',
     'recycle_bin_deleted_item' => 'Gelöschtes Element',
+    'recycle_bin_deleted_parent' => 'Übergeordnet',
     'recycle_bin_deleted_by' => 'Gelöscht von',
     'recycle_bin_deleted_at' => 'Löschzeitpunkt',
     'recycle_bin_permanently_delete' => 'Dauerhaft löschen',
@@ -107,6 +108,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'recycle_bin_restore_list' => 'Zu wiederherzustellende Elemente',
     'recycle_bin_restore_confirm' => 'Mit dieser Aktion wird das gelöschte Element einschließlich aller untergeordneten Elemente an seinen ursprünglichen Ort wiederherstellen. Wenn der ursprüngliche Ort gelöscht wurde und sich nun im Papierkorb befindet, muss auch das übergeordnete Element wiederhergestellt werden.',
     'recycle_bin_restore_deleted_parent' => 'Das übergeordnete Elements wurde ebenfalls gelöscht. Dieses Element wird weiterhin als gelöscht zählen, bis auch das übergeordnete Element wiederhergestellt wurde.',
+    'recycle_bin_restore_parent' => 'Übergeordneter Eintrag wiederherstellen',
     'recycle_bin_destroy_notification' => ':count Elemente wurden aus dem Papierkorb gelöscht.',
     'recycle_bin_restore_notification' => ':count Elemente wurden aus dem Papierkorb wiederhergestellt.',
 
@@ -120,6 +122,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'audit_table_user' => 'Benutzer',
     'audit_table_event' => 'Ereignis',
     'audit_table_related' => 'Verknüpftes Element oder Detail',
+    'audit_table_ip' => 'IP Adresse',
     'audit_table_date' => 'Aktivitätsdatum',
     'audit_date_from' => 'Zeitraum von',
     'audit_date_to' => 'Zeitraum bis',
@@ -139,6 +142,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_details' => 'Rollendetails',
     'role_name' => 'Rollenname',
     'role_desc' => 'Kurzbeschreibung der Rolle',
+    'role_mfa_enforced' => 'Benötigt Mehrfach-Faktor-Authentifizierung',
     'role_external_auth_id' => 'Externe Authentifizierungs-IDs',
     'role_system' => 'System-Berechtigungen',
     'role_manage_users' => 'Benutzer verwalten',
@@ -148,6 +152,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_manage_page_templates' => 'Seitenvorlagen verwalten',
     'role_access_api' => 'Systemzugriffs-API',
     'role_manage_settings' => 'Globaleinstellungen verwalten',
+    'role_export_content' => 'Inhalt exportieren',
     '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.',
@@ -172,7 +177,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_role' => 'Benutzerrollen',
     'users_role_desc' => 'Wählen Sie aus, welchen Rollen dieser Benutzer zugeordnet werden soll. Wenn ein Benutzer mehreren Rollen zugeordnet ist, werden die Berechtigungen dieser Rollen gestapelt und er erhält alle Fähigkeiten der zugewiesenen Rollen.',
     'users_password' => 'Benutzerpasswort',
-    'users_password_desc' => 'Legen Sie ein Passwort fest, mit dem Sie sich anmelden möchten. Diese muss mindestens 5 Zeichen lang sein.',
+    'users_password_desc' => 'Legen Sie ein Passwort fest, mit dem Sie sich anmelden möchten. Diese muss mindestens 8 Zeichen lang sein.',
     'users_send_invite_text' => 'Sie können diesem Benutzer eine Einladungs-E-Mail senden, die es ihm erlaubt, sein eigenes Passwort zu setzen, andernfalls können Sie sein Passwort selbst setzen.',
     'users_send_invite_option' => 'Benutzer-Einladungs-E-Mail senden',
     'users_external_auth_id' => 'Externe Authentifizierungs-ID',
@@ -205,6 +210,10 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_api_tokens_create' => 'Token erstellen',
     'users_api_tokens_expires' => 'Endet',
     'users_api_tokens_docs' => 'API Dokumentation',
+    'users_mfa' => 'Multi-Faktor-Authentifizierung',
+    'users_mfa_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.',
+    'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert',
+    'users_mfa_configure' => 'Methoden konfigurieren',
 
     // API Tokens
     'user_api_token_create' => 'Neuen API-Token erstellen',
@@ -227,6 +236,34 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'user_api_token_delete_confirm' => 'Sind Sie sicher, dass Sie diesen API-Token löschen möchten?',
     'user_api_token_delete_success' => 'API-Token erfolgreich gelöscht',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Neuen Webhook erstellen',
+    'webhooks_none_created' => 'Es wurden noch keine Webhooks erstellt.',
+    'webhooks_edit' => 'Webhook bearbeiten',
+    'webhooks_save' => 'Webhook speichern',
+    'webhooks_details' => 'Webhook-Details',
+    'webhooks_details_desc' => 'Geben Sie einen benutzerfreundlichen Namen und einen POST-Endpunkt als Ort an, an den die Webhook-Daten gesendet werden sollen.',
+    'webhooks_events' => 'Webhook Ereignisse',
+    'webhooks_events_desc' => 'Wählen Sie alle Ereignisse, die diesen Webhook auslösen sollen.',
+    'webhooks_events_warning' => 'Beachten Sie, dass diese Ereignisse für alle ausgewählten Ereignisse ausgelöst werden, auch wenn benutzerdefinierte Berechtigungen angewendet werden. Stellen Sie sicher, dass die Verwendung dieses Webhook keine vertraulichen Inhalte enthüllt.',
+    'webhooks_events_all' => 'Alle System-Ereignisse',
+    'webhooks_name' => 'Webhook-Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpunkt',
+    'webhooks_active' => 'Webhook aktiv',
+    'webhook_events_table_header' => 'Ereignisse',
+    'webhooks_delete' => 'Webhook löschen',
+    'webhooks_delete_warning' => 'Dies wird diesen Webhook mit dem Namen \':webhookName\' vollständig aus dem System löschen.',
+    'webhooks_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Webhook löschen möchten?',
+    'webhooks_format_example' => 'Webhook Format Beispiel',
+    'webhooks_format_example_desc' => 'Webhook Daten werden als POST-Anfrage an den konfigurierten Endpunkt als JSON im folgenden Format gesendet. Die Eigenschaften "related_item" und "url" sind optional und hängen vom Typ des ausgelösten Ereignisses ab.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -242,13 +279,16 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Estnisch',
         'fr' => 'Français',
         'he' => 'Hebräisch',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -264,6 +304,6 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 50f8a76a3e3817efd1ad4aedcc2ce41d0ecb734d..5d08c241a6871e79899916bd2614a5ffe5177806 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
     'alpha_num'            => ':attribute kann nur Buchstaben und Zahlen enthalten.',
     'array'                => ':attribute muss ein Array sein.',
+    'backup_codes'         => 'Der angegebene Code ist ungültig oder wurde bereits verwendet.',
     'before'               => ':attribute muss ein Datum vor :date sein.',
     'between'              => [
         'numeric' => ':attribute muss zwischen :min und :max liegen.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute muss eine Zeichenkette sein.',
     'timezone'             => ':attribute muss eine valide zeitzone sein.',
+    'totp'                 => 'Der angegebene Code ist ungültig oder abgelaufen.',
     'unique'               => ':attribute wird bereits verwendet.',
     'url'                  => ':attribute ist kein valides Format.',
     'uploaded'             => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.',
index 50bc29646fed9e5368a1739cfa4204f86326c1e6..02d4ed7ca1b0940e60461aa9c2ad1d3b3c9def01 100644 (file)
@@ -7,42 +7,58 @@ return [
 
     // Pages
     'page_create'                 => 'erstellt Seite',
-    'page_create_notification'    => 'Die Seite wurde erfolgreich erstellt.',
+    'page_create_notification'    => 'Die Seite wurde erfolgreich erstellt',
     'page_update'                 => 'aktualisiert Seite',
-    'page_update_notification'    => 'Die Seite wurde erfolgreich aktualisiert.',
+    'page_update_notification'    => 'Die Seite wurde erfolgreich aktualisiert',
     'page_delete'                 => 'löscht Seite',
-    'page_delete_notification'    => 'Die Seite wurde erfolgreich gelöscht.',
+    'page_delete_notification'    => 'Die Seite wurde erfolgreich gelöscht',
     'page_restore'                => 'stellt Seite wieder her',
-    'page_restore_notification'   => 'Die Seite wurde erfolgreich wiederhergestellt.',
+    'page_restore_notification'   => 'Die Seite wurde erfolgreich wiederhergestellt',
     'page_move'                   => 'verschiebt Seite',
 
     // Chapters
     'chapter_create'              => 'erstellt Kapitel',
-    'chapter_create_notification' => 'Das Kapitel wurde erfolgreich erstellt.',
+    'chapter_create_notification' => 'Das Kapitel wurde erfolgreich erstellt',
     'chapter_update'              => 'aktualisiert Kapitel',
-    'chapter_update_notification' => 'Das Kapitel wurde erfolgreich aktualisiert.',
+    'chapter_update_notification' => 'Das Kapitel wurde erfolgreich aktualisiert',
     'chapter_delete'              => 'löscht Kapitel',
-    'chapter_delete_notification' => 'Das Kapitel wurde erfolgreich gelöscht.',
+    'chapter_delete_notification' => 'Das Kapitel wurde erfolgreich gelöscht',
     'chapter_move'                => 'verschiebt Kapitel',
 
     // Books
     'book_create'                 => 'erstellt Buch',
-    'book_create_notification'    => 'Das Buch wurde erfolgreich erstellt.',
+    'book_create_notification'    => 'Das Buch wurde erfolgreich erstellt',
     'book_update'                 => 'aktualisiert Buch',
-    'book_update_notification'    => 'Das Buch wurde erfolgreich aktualisiert.',
+    'book_update_notification'    => 'Das Buch wurde erfolgreich aktualisiert',
     'book_delete'                 => 'löscht Buch',
-    'book_delete_notification'    => 'Das Buch wurde erfolgreich gelöscht.',
+    'book_delete_notification'    => 'Das Buch wurde erfolgreich gelöscht',
     'book_sort'                   => 'sortiert Buch',
-    'book_sort_notification'      => 'Das Buch wurde erfolgreich umsortiert.',
+    'book_sort_notification'      => 'Das Buch wurde erfolgreich umsortiert',
 
     // Bookshelves
-    'bookshelf_create'            => 'erstellt Bücherregal',
+    'bookshelf_create'            => 'erstelltes Bücherregal',
     'bookshelf_create_notification'    => 'Das Bücherregal wurde erfolgreich erstellt',
     'bookshelf_update'                 => 'aktualisiert Bücherregal',
-    'bookshelf_update_notification'    => 'Das Bücherregal wurde erfolgreich aktualisiert',
+    'bookshelf_update_notification'    => 'Das Bücherregal wurde erfolgreich geändert',
     'bookshelf_delete'                 => 'löscht Bücherregal',
     'bookshelf_delete_notification'    => 'Das Bücherregal wurde erfolgreich gelöscht',
 
+    // Favourites
+    'favourite_add_notification' => '":name" wurde zu deinen Favoriten hinzugefügt',
+    'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-Faktor-Methode erfolgreich konfiguriert',
+    'mfa_remove_method_notification' => 'Multi-Faktor-Methode erfolgreich entfernt',
+
+    // Webhooks
+    'webhook_create' => 'erstellter Webhook',
+    'webhook_create_notification' => 'Webhook wurde erfolgreich eingerichtet',
+    'webhook_update' => 'aktualisierter Webhook',
+    'webhook_update_notification' => 'Webhook wurde erfolgreich aktualisiert',
+    'webhook_delete' => 'gelöschter Webhook',
+    'webhook_delete_notification' => 'Webhook wurde erfolgreich gelöscht',
+
     // Other
     'commented_on'                => 'kommentiert',
     'permissions_update'          => 'aktualisierte Berechtigungen',
index 918598533969b4d69d2c38f9a03b42d367f79069..7133ca6725567d65188f8f93cb7f95bc9c091dc0 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-Mail',
     'password' => 'Passwort',
     'password_confirm' => 'Passwort bestätigen',
-    'password_hint' => 'Mindestlänge: 7 Zeichen',
+    'password_hint' => 'Muss mindestens 8 Zeichen lang sein',
     'forgot_password' => 'Passwort vergessen?',
     'remember_me' => 'Angemeldet bleiben',
     'ldap_email_hint' => 'Bitte gib eine E-Mail-Adresse ein, um diese mit dem Account zu nutzen.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Du kannst dich mit dieser E-Mail nicht registrieren.',
     'register_success' => 'Vielen Dank für deine Registrierung! Du bist jetzt registriert und eingeloggt.',
 
-
     // Password Reset
     'reset_password' => 'Passwort vergessen',
     'reset_password_send_instructions' => 'Bitte gib Deine E-Mail-Adresse ein. Danach erhältst Du eine E-Mail mit einem Link zum Zurücksetzen Deines Passwortes.',
@@ -46,9 +45,8 @@ return [
     'reset_password_sent' => 'Ein Link zum Zurücksetzen des Passworts wird an :email gesendet, wenn diese E-Mail-Adresse im System gefunden wird.',
     'reset_password_success' => 'Dein Passwort wurde erfolgreich zurückgesetzt.',
     'email_reset_subject' => 'Passwort zurücksetzen für :appName',
-    'email_reset_text' => 'Du erhältsts diese E-Mail, weil jemand versucht hat, Dein Passwort zurückzusetzen.',
-    'email_reset_not_requested' => 'Wenn du das zurücksetzen des Passworts nicht angefordert hast, ist keine weitere Aktion erforderlich.',
-
+    'email_reset_text' => 'Du erhältst diese E-Mail, weil jemand versucht hat, Dein Passwort zurückzusetzen.',
+    'email_reset_not_requested' => 'Wenn du das Zurücksetzen des Passworts nicht angefordert hast, ist keine weitere Aktion erforderlich.',
 
     // Email Confirmation
     'email_confirm_subject' => 'Bestätige Deine E-Mail-Adresse für :appName',
@@ -56,7 +54,7 @@ return [
     'email_confirm_text' => 'Bitte bestätige Deine E-Mail-Adresse, indem Du auf die Schaltfläche klickst:',
     'email_confirm_action' => 'E-Mail-Adresse bestätigen',
     'email_confirm_send_error' => 'Leider konnte die für die Registrierung notwendige E-Mail zur Bestätigung Deiner E-Mail-Adresse nicht versandt werden. Bitte kontaktiere den Systemadministrator!',
-    'email_confirm_success' => 'Deine E-Mail-Adresse wurde bestätigt!',
+    'email_confirm_success' => 'Ihre E-Mail wurde bestätigt! Sie sollten nun in der Lage sein, sich mit dieser E-Mail-Adresse anzumelden.',
     'email_confirm_resent' => 'Bestätigungs-E-Mail wurde erneut versendet, bitte überprüfe Deinen Posteingang.',
 
     'email_not_confirmed' => 'E-Mail-Adresse ist nicht bestätigt',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Willkommen bei :appName!',
     'user_invite_page_text' => 'Um die Anmeldung abzuschließen und Zugriff auf :appName zu bekommen muss noch ein Passwort festgelegt werden. Dieses wird in Zukunft zum Einloggen benötigt.',
     'user_invite_page_confirm_button' => 'Passwort bestätigen',
-    'user_invite_success' => 'Das Passwort wurde gesetzt, du hast nun Zugriff auf :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Passwort gesetzt, Sie sollten nun in der Lage sein, sich mit Ihrem Passwort an :appName anzumelden!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Multi-Faktor-Authentifizierung einrichten',
+    'mfa_setup_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.',
+    'mfa_setup_configured' => 'Bereits konfiguriert',
+    'mfa_setup_reconfigure' => 'Umkonfigurieren',
+    'mfa_setup_remove_confirmation' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode entfernen möchten?',
+    'mfa_setup_action' => 'Einrichtung',
+    'mfa_backup_codes_usage_limit_warning' => 'Sie haben weniger als 5 Backup-Codes übrig, Bitte erstellen und speichern Sie ein neues Set bevor Sie keine Codes mehr haben, um zu verhindern, dass Sie von Ihrem Konto gesperrt werden.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Code',
+    'mfa_option_backup_codes_desc' => 'Speichern Sie sicher eine Reihe von einmaligen Backup-Codes, die Sie eingeben können, um Ihre Identität zu überprüfen.',
+    'mfa_gen_confirm_and_enable' => 'Bestätigen und aktivieren',
+    'mfa_gen_backup_codes_title' => 'Backup-Codes einrichten',
+    'mfa_gen_backup_codes_desc' => 'Speichern Sie die folgende Liste der Codes an einem sicheren Ort. Wenn Sie auf das System zugreifen, können Sie einen der Codes als zweiten Authentifizierungsmechanismus verwenden.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Jeder Code kann nur einmal verwendet werden',
+    'mfa_gen_totp_title' => 'Mobile App einrichten',
+    'mfa_gen_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scannen Sie den QR-Code unten mit Ihrer bevorzugten Authentifizierungs-App, um loszulegen.',
+    'mfa_gen_totp_verify_setup' => 'Setup überprüfen',
+    'mfa_gen_totp_verify_setup_desc' => 'Überprüfen Sie, dass alles funktioniert, indem Sie einen Code in Ihrer Authentifizierungs-App in das Eingabefeld unten eingeben:',
+    'mfa_gen_totp_provide_code_here' => 'Geben Sie hier Ihre App generierten Code ein',
+    'mfa_verify_access' => 'Zugriff überprüfen',
+    'mfa_verify_access_desc' => 'Ihr Benutzerkonto erfordert, dass Sie Ihre Identität über eine zusätzliche Verifikationsebene bestätigen, bevor Sie den Zugriff gewähren. Überprüfen Sie mit einer Ihrer konfigurierten Methoden, um fortzufahren.',
+    'mfa_verify_no_methods' => 'Keine Methoden konfiguriert',
+    'mfa_verify_no_methods_desc' => 'Es konnten keine Mehrfach-Faktor-Authentifizierungsmethoden für Ihr Konto gefunden werden. Sie müssen mindestens eine Methode einrichten, bevor Sie Zugriff erhalten.',
+    'mfa_verify_use_totp' => 'Mit einer mobilen App verifizieren',
+    'mfa_verify_use_backup_codes' => 'Mit einem Backup-Code überprüfen',
+    'mfa_verify_backup_code' => 'Backup-Code',
+    'mfa_verify_backup_code_desc' => 'Geben Sie einen Ihrer verbleibenden Backup-Codes unten ein:',
+    'mfa_verify_backup_code_enter_here' => 'Backup-Code hier eingeben',
+    'mfa_verify_totp_desc' => 'Geben Sie den Code ein, der mit Ihrer mobilen App generiert wurde:',
+    'mfa_setup_login_notification' => 'Multi-Faktor-Methode konfiguriert. Bitte melden Sie sich jetzt erneut mit der konfigurierten Methode an.',
+];
index 84733659ee4503b72fe1c5167c6a7ff576bc8732..702003f3bea7264f7c73b093a0e386d460147d62 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rolle',
     'cover_image' => 'Titelbild',
     'cover_image_description' => 'Das Bild sollte eine Auflösung von 440x250px haben.',
-    
+
     // Actions
     'actions' => 'Aktionen',
     'view' => 'Anzeigen',
@@ -33,13 +33,20 @@ return [
     'copy' => 'Kopieren',
     'reply' => 'Antworten',
     'delete' => 'Löschen',
-    'delete_confirm' => 'Löschen Bestätigen',
+    'delete_confirm' => 'Löschen bestätigen',
     'search' => 'Suchen',
     'search_clear' => 'Suche löschen',
     'reset' => 'Zurücksetzen',
     'remove' => 'Entfernen',
     'add' => 'Hinzufügen',
+    'configure' => 'Konfigurieren',
     'fullscreen' => 'Vollbild',
+    'favourite' => 'Favoriten',
+    'unfavourite' => 'Kein Favorit',
+    'next' => 'Nächste',
+    'previous' => 'Vorheriges',
+    'filter_active' => 'Gesetzte Filter:',
+    'filter_clear' => 'Filter löschen',
 
     // Sort Options
     'sort_options' => 'Sortieroptionen',
@@ -52,10 +59,11 @@ return [
     'sort_updated_at' => 'Aktualisierungsdatum',
 
     // Misc
-    'deleted_user' => 'Gelöschte Benutzer',
+    'deleted_user' => 'Gelöschter Benutzer',
     'no_activity' => 'Keine Aktivitäten zum Anzeigen',
     'no_items' => 'Keine Einträge gefunden.',
     'back_to_top' => 'nach oben',
+    'skip_to_main_content' => 'Direkt zum Hauptinhalt',
     'toggle_details' => 'Details zeigen/verstecken',
     'toggle_thumbnails' => 'Thumbnails zeigen/verstecken',
     'details' => 'Details',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Listenansicht',
     'default' => 'Voreinstellung',
     'breadcrumb' => 'Brotkrumen',
+    'status' => 'Status',
+    'status_active' => 'Aktiv',
+    'status_inactive' => 'Inaktiv',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Header-Menü erweitern',
index 50dea6cf3f857e07b11fc5b1234dffa46a5673a4..6e57bcf0fd1919b14e898f8c77715e3bd8572e64 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Bilder',
     'my_recent_drafts' => 'Meine kürzlichen Entwürfe',
     'my_recently_viewed' => 'Kürzlich von mir angesehen',
+    'my_most_viewed_favourites' => 'Meine meistgesehenen Favoriten',
+    'my_favourites' => 'Meine Favoriten',
     'no_pages_viewed' => 'Du hast bisher keine Seiten angesehen.',
     'no_pages_recently_created' => 'Du hast bisher keine Seiten angelegt.',
     'no_pages_recently_updated' => 'Du hast bisher keine Seiten aktualisiert.',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'HTML-Datei',
     'export_pdf' => 'PDF-Datei',
     'export_text' => 'Textdatei',
+    'export_md' => 'Markdown-Dateir',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Regal-Berechtigungen',
     'shelves_permissions_updated' => 'Regal-Berechtigungen aktualisiert',
     'shelves_permissions_active' => 'Regal-Berechtigungen aktiv',
+    'shelves_permissions_cascade_warning' => 'Die Berechtigungen in Bücherregalen werden nicht automatisch auf enthaltene Bücher kaskadiert, weil ein Buch in mehreren Regalen existieren kann. Berechtigungen können jedoch mit der unten stehenden Option in untergeordnete Bücher kopiert werden.',
     'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zum Buch',
     'shelves_copy_permissions' => 'Berechtigungen kopieren',
     'shelves_copy_permissions_explain' => 'Hiermit werden die Berechtigungen des aktuellen Regals auf alle enthaltenen Bücher übertragen. Überprüfe vor der Aktivierung, ob alle Berechtigungsänderungen am aktuellen Regal gespeichert wurden.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Kapitel zuletzt',
     'books_sort_show_other' => 'Andere Bücher anzeigen',
     'books_sort_save' => 'Neue Reihenfolge speichern',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Kapitel',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Kapitel verschieben',
     'chapters_move_named' => 'Kapitel ":chapterName" verschieben',
     'chapter_move_success' => 'Das Kapitel wurde in das Buch ":bookName" verschoben.',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Kapitel-Berechtigungen',
     'chapters_empty' => 'Aktuell sind keine Kapitel diesem Buch hinzugefügt worden.',
     'chapters_permissions_active' => 'Kapitel-Berechtigungen aktiv',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Neue Seite',
     'pages_editing_draft_notification' => 'Du bearbeitest momenten einen Entwurf, der zuletzt :timeDiff gespeichert wurde.',
     'pages_draft_edited_notification' => 'Diese Seite wurde seit diesem Zeitpunkt verändert. Wir empfehlen Ihnen, diesen Entwurf zu verwerfen.',
+    'pages_draft_page_changed_since_creation' => 'Diese Seite wurde seit der Erstellung dieses Entwurfs aktualisiert. Es wird empfohlen, diesen Entwurf zu verwerfen oder darauf zu achten, dass keine Seitenänderungen überschrieben werden.',
     'pages_draft_edit_active' => [
         'start_a' => ':count Benutzer bearbeiten derzeit diese Seite.',
         'start_b' => ':userName bearbeitet jetzt diese Seite.',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Füge Schlagwörter hinzu, um ihren Inhalt zu kategorisieren.\nDu kannst einen erklärenden Inhalt hinzufügen, um eine genauere Unterteilung vorzunehmen.",
     'tags_add' => 'Weiteres Schlagwort hinzufügen',
     'tags_remove' => 'Diesen Tag entfernen',
+    'tags_usages' => 'Gesamte Tagnutzung',
+    'tags_assigned_pages' => 'Zugewiesen zu Seiten',
+    'tags_assigned_chapters' => 'Zugewiesen zu Kapiteln',
+    'tags_assigned_books' => 'Zugewiesen zu Büchern',
+    'tags_assigned_shelves' => 'Zugewiesen zu Regalen',
+    'tags_x_unique_values' => ':count eindeutige Werte',
+    'tags_all_values' => 'Alle Werte',
+    'tags_view_tags' => 'Tags anzeigen',
+    'tags_view_existing_tags' => 'Vorhandene Tags anzeigen',
+    'tags_list_empty_hint' => 'Tags können über die Seitenleiste des Seiteneditors oder beim Bearbeiten der Details eines Buches, Kapitels oder Regals zugewiesen werden.',
     'attachments' => 'Anhänge',
     'attachments_explain' => 'Du kannst auf Deiner Seite Dateien hochladen oder Links hinzufügen. Diese werden in der Seitenleiste angezeigt.',
     'attachments_explain_instant_save' => 'Änderungen werden direkt gespeichert.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Bist Du sicher, dass Du diese Revision löschen möchtest?',
     'revision_restore_confirm' => 'Sind Sie sicher, dass Sie diese Revision wiederherstellen wollen? Der aktuelle Seiteninhalt wird ersetzt.',
     'revision_delete_success' => 'Revision gelöscht',
-    'revision_cannot_delete_latest' => 'Die letzte Version kann nicht gelöscht werden.'
+    'revision_cannot_delete_latest' => 'Die letzte Version kann nicht gelöscht werden.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 656c3c23647133f1d12b38e0714bc68dd48f9a7c..d44b9a65b45406f4ff24e8d5acb81f2236e1137c 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden',
     'saml_invalid_response_id' => 'Die Anfrage vom externen Authentifizierungssystem wird von einem von dieser Anwendung gestarteten Prozess nicht erkannt. Das Zurückgehen nach einem Login könnte dieses Problem verursachen.',
     'saml_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen',
+    'oidc_already_logged_in' => 'Bereits angemeldet',
+    'oidc_user_not_registered' => 'Der Benutzer :name ist nicht registriert und die automatische Registrierung ist deaktiviert',
+    'oidc_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden',
+    'oidc_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen',
     'social_no_action_defined' => 'Es ist keine Aktion definiert',
     'social_login_bad_response' => "Fehler bei :socialAccount Login: \n:error",
     'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melde dich mit dem :socialAccount-Konto an.',
@@ -33,7 +37,7 @@ return [
     'social_account_register_instructions' => 'Wenn Du bisher kein Social-Media Konto besitzt, kannst Du ein solches Konto mit der :socialAccount Option anlegen.',
     'social_driver_not_found' => 'Treiber für Social-Media-Konten nicht gefunden',
     'social_driver_not_configured' => 'Ihr :socialAccount-Konto ist nicht korrekt konfiguriert.',
-    'invite_token_expired' => 'Dieser Einladungslink ist abgelaufen. Sie können stattdessen versuchen, Ihr Passwort zurückzusetzen.',
+    'invite_token_expired' => 'Dieser Einladungslink ist abgelaufen. Du kannst stattdessen versuchen, dein Passwort zurückzusetzen.',
 
     // System
     'path_not_writable' => 'Die Datei kann nicht in den angegebenen Pfad :filePath hochgeladen werden. Stelle sicher, dass dieser Ordner auf dem Server beschreibbar ist.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Seite nicht gefunden',
     'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Du angefordert hast, wurde nicht gefunden.',
     'sorry_page_not_found_permission_warning' => 'Wenn du erwartet hast, dass diese Seite existiert, hast du möglicherweise nicht die Berechtigung, sie anzuzeigen.',
+    'image_not_found' => 'Bild nicht gefunden',
+    'image_not_found_subtitle' => 'Entschuldigung. Das angeforderte Bild wurde nicht gefunden.',
+    'image_not_found_details' => 'Wenn Sie erwartet haben, dass dieses Bild existiert, könnte es gelöscht worden sein.',
     'return_home' => 'Zurück zur Startseite',
     'error_occurred' => 'Es ist ein Fehler aufgetreten',
     'app_down' => ':appName befindet sich aktuell im Wartungsmodus.',
index 440f54d965eebb462ea94ca66380c70f38caaad6..190985fea2bcb60797be56e721c663f01327fd16 100644 (file)
@@ -75,7 +75,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     // Maintenance settings
     'maint' => 'Wartung',
     'maint_image_cleanup' => 'Bilder bereinigen',
-    'maint_image_cleanup_desc' => "Überprüft Seiten- und Versionsinhalte auf ungenutzte und mehrfach vorhandene Bilder. Erstelle vor dem Start ein Backup Deiner Datenbank und Bilder.",
+    'maint_image_cleanup_desc' => 'Überprüft Seiten- und Versionsinhalte auf ungenutzte und mehrfach vorhandene Bilder. Erstelle vor dem Start ein Backup Deiner Datenbank und Bilder.',
     'maint_delete_images_only_in_revisions' => 'Lösche auch Bilder, die nur in alten Seitenüberarbeitungen vorhanden sind',
     'maint_image_cleanup_run' => 'Reinigung starten',
     'maint_image_cleanup_warning' => ':count eventuell unbenutze Bilder wurden gefunden. Möchtest Du diese Bilder löschen?',
@@ -95,6 +95,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'recycle_bin' => 'Papierkorb',
     'recycle_bin_desc' => 'Hier können Sie gelöschte Einträge wiederherstellen oder sie dauerhaft aus dem System entfernen. Diese Liste ist nicht gefiltert, im Gegensatz zu ähnlichen Aktivitätslisten im System, wo Berechtigungsfilter angewendet werden.',
     'recycle_bin_deleted_item' => 'Gelöschter Eintrag',
+    'recycle_bin_deleted_parent' => 'Übergeordnet',
     'recycle_bin_deleted_by' => 'Gelöscht von',
     'recycle_bin_deleted_at' => 'Löschzeitpunkt',
     'recycle_bin_permanently_delete' => 'Dauerhaft löschen',
@@ -107,6 +108,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'recycle_bin_restore_list' => 'Wiederherzustellende Einträge',
     'recycle_bin_restore_confirm' => 'Mit dieser Aktion wird der gelöschte Eintrag einschließlich aller untergeordneten Einträge an seinen ursprünglichen Ort wiederherstellen. Wenn der ursprüngliche Ort gelöscht wurde und sich nun im Papierkorb befindet, muss auch der übergeordnete Eintrag wiederhergestellt werden.',
     'recycle_bin_restore_deleted_parent' => 'Der übergeordnete Eintrag wurde ebenfalls gelöscht. Dieser Eintrag wird weiterhin als gelöscht zählen, bis auch der übergeordnete Eintrag wiederhergestellt wurde.',
+    'recycle_bin_restore_parent' => 'Übergeordneter Eintrag wiederherstellen',
     'recycle_bin_destroy_notification' => ':count Einträge wurden aus dem Papierkorb gelöscht.',
     'recycle_bin_restore_notification' => ':count Einträge wurden aus dem Papierkorb wiederhergestellt.',
 
@@ -120,6 +122,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'audit_table_user' => 'Benutzer',
     'audit_table_event' => 'Ereignis',
     'audit_table_related' => 'Verknüpfter Eintrag oder Detail',
+    'audit_table_ip' => 'IP Adresse',
     'audit_table_date' => 'Aktivitätsdatum',
     'audit_date_from' => 'Zeitraum von',
     'audit_date_to' => 'Zeitraum bis',
@@ -139,6 +142,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_details' => 'Rollendetails',
     'role_name' => 'Rollenname',
     'role_desc' => 'Kurzbeschreibung der Rolle',
+    'role_mfa_enforced' => 'Benötigt Mehrfach-Faktor-Authentifizierung',
     'role_external_auth_id' => 'Externe Authentifizierungs-IDs',
     'role_system' => 'System-Berechtigungen',
     'role_manage_users' => 'Benutzer verwalten',
@@ -148,6 +152,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'role_manage_page_templates' => 'Seitenvorlagen verwalten',
     'role_access_api' => 'Systemzugriffs-API',
     'role_manage_settings' => 'Globaleinstellungen verwalten',
+    'role_export_content' => 'Inhalt exportieren',
     '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.',
@@ -172,7 +177,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_role' => 'Benutzerrollen',
     'users_role_desc' => 'Wählen Sie aus, welchen Rollen dieser Benutzer zugeordnet werden soll. Wenn ein Benutzer mehreren Rollen zugeordnet ist, werden die Berechtigungen dieser Rollen gestapelt und er erhält alle Fähigkeiten der zugewiesenen Rollen.',
     'users_password' => 'Benutzerpasswort',
-    'users_password_desc' => 'Legen Sie ein Passwort fest, mit dem Sie sich anmelden möchten. Diese muss mindestens 5 Zeichen lang sein.',
+    'users_password_desc' => 'Lege ein Passwort fest, mit dem du dich anmelden möchtest. Diese muss mindestens 8 Zeichen lang sein.',
     'users_send_invite_text' => 'Du kannst diesem Benutzer eine Einladungs-E-Mail senden, die es ihm erlaubt, sein eigenes Passwort zu setzen, andernfalls kannst du sein Passwort selbst setzen.',
     'users_send_invite_option' => 'Benutzer-Einladungs-E-Mail senden',
     'users_external_auth_id' => 'Externe Authentifizierungs-ID',
@@ -205,6 +210,10 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_api_tokens_create' => 'Token erstellen',
     'users_api_tokens_expires' => 'Endet',
     'users_api_tokens_docs' => 'API Dokumentation',
+    'users_mfa' => 'Multi-Faktor-Authentifizierung',
+    'users_mfa_desc' => 'Richte die Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für dein Benutzerkonto ein.',
+    'users_mfa_x_methods' => ':count Methode konfiguriert|:count Methoden konfiguriert',
+    'users_mfa_configure' => 'Methoden konfigurieren',
 
     // API Tokens
     'user_api_token_create' => 'Neuen API-Token erstellen',
@@ -227,6 +236,34 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'user_api_token_delete_confirm' => 'Bist du sicher, dass du diesen API-Token löschen möchtest?',
     'user_api_token_delete_success' => 'API-Token erfolgreich gelöscht',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Neuen Webhook erstellen',
+    'webhooks_none_created' => 'Es wurden noch keine Webhooks erstellt.',
+    'webhooks_edit' => 'Webhook bearbeiten',
+    'webhooks_save' => 'Webhook speichern',
+    'webhooks_details' => 'Webhook-Details',
+    'webhooks_details_desc' => 'Geben Sie einen benutzerfreundlichen Namen und einen POST-Endpunkt als Ort an, an den die Webhook-Daten gesendet werden sollen.',
+    'webhooks_events' => 'Webhook Ereignisse',
+    'webhooks_events_desc' => 'Wählen Sie alle Ereignisse, die diesen Webhook auslösen sollen.',
+    'webhooks_events_warning' => 'Beachten Sie, dass diese Ereignisse für alle ausgewählten Ereignisse ausgelöst werden, auch wenn benutzerdefinierte Berechtigungen angewendet werden. Stellen Sie sicher, dass die Verwendung dieses Webhook keine vertraulichen Inhalte enthüllt.',
+    'webhooks_events_all' => 'Alle System-Ereignisse',
+    'webhooks_name' => 'Webhook-Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpunkt',
+    'webhooks_active' => 'Webhook aktiv',
+    'webhook_events_table_header' => 'Ereignisse',
+    'webhooks_delete' => 'Webhook löschen',
+    'webhooks_delete_warning' => 'Dies wird diesen Webhook mit dem Namen \':webhookName\' vollständig aus dem System löschen.',
+    'webhooks_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Webhook löschen möchten?',
+    'webhooks_format_example' => 'Webhook Format Beispiel',
+    'webhooks_format_example_desc' => 'Webhook Daten werden als POST-Anfrage an den konfigurierten Endpunkt als JSON im folgenden Format gesendet. Die Eigenschaften "related_item" und "url" sind optional und hängen vom Typ des ausgelösten Ereignisses ab.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -242,13 +279,16 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Estnisch',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -264,6 +304,6 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 42456da6eb3a13593cddbee4ea72643faad0dacf..6603eccc89bd54d7486f108eb73c9c3dccd129d2 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
     'alpha_num'            => ':attribute kann nur Buchstaben und Zahlen enthalten.',
     'array'                => ':attribute muss ein Array sein.',
+    'backup_codes'         => 'Der angegebene Code ist ungültig oder wurde bereits verwendet.',
     'before'               => ':attribute muss ein Datum vor :date sein.',
     'between'              => [
         'numeric' => ':attribute muss zwischen :min und :max liegen.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute muss eine Zeichenkette sein.',
     'timezone'             => ':attribute muss eine valide zeitzone sein.',
+    'totp'                 => 'Der angegebene Code ist ungültig oder abgelaufen.',
     'unique'               => ':attribute wird bereits verwendet.',
     'url'                  => ':attribute ist kein valides Format.',
     'uploaded'             => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.',
index fe937b061930262a060465699446647adab763d9..83a374d66a083f3f25fc41523f87c0a58deab214 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'created page',
-    'page_create_notification'    => 'Page Successfully Created',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'updated page',
-    'page_update_notification'    => 'Page Successfully Updated',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'deleted page',
-    'page_delete_notification'    => 'Page Successfully Deleted',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'restored page',
-    'page_restore_notification'   => 'Page Successfully Restored',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'moved page',
 
     // Chapters
     'chapter_create'              => 'created chapter',
-    'chapter_create_notification' => 'Chapter Successfully Created',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'updated chapter',
-    'chapter_update_notification' => 'Chapter Successfully Updated',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'deleted chapter',
-    'chapter_delete_notification' => 'Chapter Successfully Deleted',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'moved chapter',
 
     // Books
     'book_create'                 => 'created book',
-    'book_create_notification'    => 'Book Successfully Created',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'updated book',
-    'book_update_notification'    => 'Book Successfully Updated',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'deleted book',
-    'book_delete_notification'    => 'Book Successfully Deleted',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorted book',
-    'book_sort_notification'      => 'Book Successfully Re-sorted',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'created Bookshelf',
-    'bookshelf_create_notification'    => 'Bookshelf Successfully Created',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'updated bookshelf',
-    'bookshelf_update_notification'    => 'Bookshelf Successfully Updated',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'deleted bookshelf',
-    'bookshelf_delete_notification'    => 'Bookshelf Successfully Deleted',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'commented on',
index d64fce93a62d90889b2297a9e4f6482ad9046475..ad0d516bb1d5e2f6a9d7006d422cf1ad9b910141 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Email',
     'password' => 'Password',
     'password_confirm' => 'Confirm Password',
-    'password_hint' => 'Must be over 7 characters',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Forgot Password?',
     'remember_me' => 'Remember Me',
     'ldap_email_hint' => 'Please enter an email to use for this account.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'That email domain does not have access to this application',
     'register_success' => 'Thanks for signing up! You are now registered and signed in.',
 
-
     // Password Reset
     'reset_password' => 'Reset Password',
     'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.',
@@ -49,14 +48,13 @@ return [
     '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.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirm your email on :appName',
     'email_confirm_greeting' => 'Thanks for joining :appName!',
     'email_confirm_text' => 'Please confirm your email address by clicking the button below:',
     'email_confirm_action' => 'Confirm Email',
     'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.',
-    'email_confirm_success' => 'Your email has been confirmed!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.',
 
     'email_not_confirmed' => 'Email Address Not Confirmed',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Welcome to :appName!',
     'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
     'user_invite_page_confirm_button' => 'Confirm Password',
-    'user_invite_success' => 'Password set, you now have access to :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 855c1c807488a8af9860f35f24004e0bb055edc1..2f09e53d1fe88389e923863d875c43e8d2713d4a 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Role',
     'cover_image' => 'Cover image',
     'cover_image_description' => 'This image should be approx 440x250px.',
-    
+
     // Actions
     'actions' => 'Actions',
     'view' => 'View',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Reset',
     'remove' => 'Remove',
     'add' => 'Add',
+    'configure' => 'Configure',
     'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sort Options',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'No activity to show',
     'no_items' => 'No items available',
     'back_to_top' => 'Back to top',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Toggle Details',
     'toggle_thumbnails' => 'Toggle Thumbnails',
     'details' => 'Details',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'List View',
     'default' => 'Default',
     'breadcrumb' => 'Breadcrumb',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index 1661bae57cae5184af9381b15f79fe0c301d003a..4e4bbccd3f4227ad721134d722e20f472a518c8e 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Images',
     'my_recent_drafts' => 'My Recent Drafts',
     'my_recently_viewed' => 'My Recently Viewed',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'You have not viewed any pages',
     'no_pages_recently_created' => 'No pages have been recently created',
     'no_pages_recently_updated' => 'No pages have been recently updated',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Contained Web File',
     'export_pdf' => 'PDF File',
     'export_text' => 'Plain Text File',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Permissions',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Bookshelf Permissions',
     'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
     'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
     'shelves_copy_permissions' => 'Copy Permissions',
     'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Chapters Last',
     'books_sort_show_other' => 'Show Other Books',
     'books_sort_save' => 'Save New Order',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Chapter',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Move Chapter',
     'chapters_move_named' => 'Move Chapter :chapterName',
     'chapter_move_success' => 'Chapter moved to :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Chapter Permissions',
     'chapters_empty' => 'No pages are currently in this chapter.',
     'chapters_permissions_active' => 'Chapter Permissions Active',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'New Page',
     'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.',
     'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count users have started editing this page',
         'start_b' => ':userName has started editing this page',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
     'tags_add' => 'Add another tag',
     'tags_remove' => 'Remove this tag',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Attachments',
     'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.',
     'attachments_explain_instant_save' => 'Changes here are saved instantly.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
     'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
     'revision_delete_success' => 'Revision deleted',
-    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
+    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 79024e482ed69efa633116592f9b7c83a0bcc93a..f023b6bdf67871ce3830d39ed540a7232510973a 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
     'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
     'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'No action defined',
     'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
     'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Page Not Found',
     'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.',
     'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Return to home',
     'error_occurred' => 'An Error Occurred',
     'app_down' => ':appName is down right now',
index ab7f153224afd41bb90e9cd54a9472895727b619..65e2e5264a93cce73e7a520d14acd3645a9be5bb 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Maintenance',
     'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => 'Run Cleanup',
     'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Recycle Bin',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
     'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Deleted By',
     'recycle_bin_deleted_at' => 'Deletion Time',
     'recycle_bin_permanently_delete' => 'Permanently Delete',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items to be Restored',
     'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'User',
     'audit_table_event' => 'Event',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Role Details',
     'role_name' => 'Role Name',
     'role_desc' => 'Short Description of Role',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'External Authentication IDs',
     'role_system' => 'System Permissions',
     'role_manage_users' => 'Manage users',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Manage app settings',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'User Roles',
     'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
     'users_password' => 'User Password',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
     'users_send_invite_option' => 'Send user invite email',
     'users_external_auth_id' => 'External Authentication ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Create Token',
     'users_api_tokens_expires' => 'Expires',
     'users_api_tokens_docs' => 'API Documentation',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Create API Token',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
     'user_api_token_delete_success' => 'API token successfully deleted',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 4031de2ae743b75bafd49195ff06b29a347cbf22..1963b0df2f9a9bb3d2586fc71fbc2ca04e02f97d 100644 (file)
@@ -15,6 +15,7 @@ return [
     '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.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => 'The :attribute must be a date before :date.',
     'between'              => [
         'numeric' => 'The :attribute must be between :min and :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'The :attribute must be a string.',
     'timezone'             => 'The :attribute must be a valid zone.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => 'The :attribute has already been taken.',
     'url'                  => 'The :attribute format is invalid.',
     'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
index b67781e18969774d39ed646fcabfb5c25335ea66..f48eab590677ab60d3d04953c7856a53acf09d14 100644 (file)
@@ -43,6 +43,22 @@ return [
     'bookshelf_delete'                 => 'estante eliminado',
     'bookshelf_delete_notification'    => 'Estante eliminado correctamente',
 
+    // Favourites
+    'favourite_add_notification' => '".name" ha sido añadido a sus favoritos',
+    'favourite_remove_notification' => '".name" ha sido eliminado de sus favoritos',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Método de Autenticación en Dos Pasos configurado correctamente',
+    'mfa_remove_method_notification' => 'Método de Autenticación en Dos Pasos eliminado correctamente',
+
+    // Webhooks
+    'webhook_create' => 'webhook creado',
+    'webhook_create_notification' => 'Webhook creado correctamente',
+    'webhook_update' => 'webhook actualizado',
+    'webhook_update_notification' => 'Webhook actualizado correctamente',
+    'webhook_delete' => 'webhook eliminado',
+    'webhook_delete_notification' => 'Webhook eliminado correctamente',
+
     // Other
     'commented_on'                => 'comentada el',
     'permissions_update'          => 'permisos actualizados',
index 25fc5b650d2d62eafd641de2a786879b8adad640..f354321383955a9d23b22c425a83d7057c869556 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Correo electrónico',
     'password' => 'Contraseña',
     'password_confirm' => 'Confirmar Contraseña',
-    'password_hint' => 'Debe contener más de 7 caracteres',
+    'password_hint' => 'Debe contener al menos 8 caracteres',
     'forgot_password' => '¿Contraseña Olvidada?',
     'remember_me' => 'Recordarme',
     'ldap_email_hint' => 'Por favor introduzca un mail para utilizar con esta cuenta.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Este dominio de correo electrónico no tiene acceso a esta aplicación',
     'register_success' => '¡Gracias por registrarse! Ahora se encuentra registrado y logueado.',
 
-
     // Password Reset
     'reset_password' => 'Resetear Contraseña',
     'reset_password_send_instructions' => 'Introduzca su correo electrónico a continuación y le será enviado un correo con un link para la restauración',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Está recibiendo este correo electrónico debido a que recibimos una solicitud de reseteo de contraseña de su cuenta.',
     'email_reset_not_requested' => 'Si no ha solicitado un reseteo de la contraseña, no es requerida ninguna acción por su parte.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirme su correo electrónico en :appName',
     'email_confirm_greeting' => '¡Gracias por unirse a :appName!',
     'email_confirm_text' => 'Por favor confirme su dirección de correo electrónico haciendo click en el siguiente botón:',
     'email_confirm_action' => 'Confirmar Correo Electrónico',
     'email_confirm_send_error' => 'Confirmation de correo electrónico requerida pero el sistema no pudo enviar el correo. Contacte con el administrador para asegurarse de que el correo electrónico está configurado correctamente.',
-    'email_confirm_success' => '¡Su correo electrónico ha sido confirmado!',
+    'email_confirm_success' => '¡Tu correo electrónico ha sido confirmado! Ahora deberías poder iniciar sesión usando esta dirección de correo electrónico.',
     'email_confirm_resent' => 'correo electrónico de confirmación reenviado, compruebe su bandeja de entrada.',
 
     'email_not_confirmed' => 'Dirección de Correo Electrónico no confirmada',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => '¡Bienvenido a :appName!',
     'user_invite_page_text' => 'Para completar la cuenta y tener acceso es necesario que configure una contraseña que se utilizará para entrar en :appName en futuros accesos.',
     'user_invite_page_confirm_button' => 'Confirmar Contraseña',
-    'user_invite_success' => '¡Contraseña guardada, ya tiene acceso a :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Contraseña guardada, ¡ahora debería ser capaz de iniciar sesión usando su contraseña establecida para acceder a :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Configurar Autenticación en Dos Pasos',
+    'mfa_setup_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta de usuario.',
+    'mfa_setup_configured' => 'Ya está configurado',
+    'mfa_setup_reconfigure' => 'Reconfigurar',
+    'mfa_setup_remove_confirmation' => '¿Está seguro de que desea eliminar este método de autenticación en dos pasos?',
+    'mfa_setup_action' => 'Configuración',
+    'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genera y almacena un nuevo conjunto antes de que te quedes sin códigos para evitar que te bloquees fuera de tu cuenta.',
+    'mfa_option_totp_title' => 'Aplicación para móviles',
+    'mfa_option_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Códigos de Respaldo',
+    'mfa_option_backup_codes_desc' => 'Almacena de forma segura un conjunto de códigos de respaldo de un solo uso que puedes introducir para verificar tu identidad.',
+    'mfa_gen_confirm_and_enable' => 'Confirmar y Activar',
+    'mfa_gen_backup_codes_title' => 'Configuración de Códigos de Respaldo',
+    'mfa_gen_backup_codes_desc' => 'Guarda la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrás usar uno de los códigos como un segundo mecanismo de autenticación.',
+    'mfa_gen_backup_codes_download' => 'Descargar Códigos',
+    'mfa_gen_backup_codes_usage_warning' => 'Cada código sólo puede utilizarse una vez',
+    'mfa_gen_totp_title' => 'Configuración de Aplicación móvil',
+    'mfa_gen_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Escanea el código QR mostrado a continuación usando tu aplicación de autenticación preferida para empezar.',
+    'mfa_gen_totp_verify_setup' => 'Verificar Configuración',
+    'mfa_gen_totp_verify_setup_desc' => 'Verifica que todo está funcionando introduciendo un código, generado en tu aplicación de autenticación, en el campo de texto a continuación:',
+    'mfa_gen_totp_provide_code_here' => 'Introduce aquí tu código generado por la aplicación',
+    'mfa_verify_access' => 'Verificar Acceso',
+    'mfa_verify_access_desc' => 'Tu cuenta de usuario requiere que confirmes tu identidad a través de un nivel adicional de verificación antes de que te conceda el acceso. Verifica tu identidad usando uno de los métodos configurados para continuar.',
+    'mfa_verify_no_methods' => 'No hay Métodos Configurados',
+    'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación en dos pasos para tu cuenta. Tendrás que configurar al menos un método antes de obtener acceso.',
+    'mfa_verify_use_totp' => 'Verificar usando una aplicación móvil',
+    'mfa_verify_use_backup_codes' => 'Verificar usando un código de respaldo',
+    'mfa_verify_backup_code' => 'Códigos de Respaldo',
+    'mfa_verify_backup_code_desc' => 'Introduzca uno de sus códigos de respaldo restantes a continuación:',
+    'mfa_verify_backup_code_enter_here' => 'Introduce el código de respaldo aquí',
+    'mfa_verify_totp_desc' => 'Introduzca el código, generado con tu aplicación móvil, a continuación:',
+    'mfa_setup_login_notification' => 'Método de dos factores configurado. Por favor, inicia sesión de nuevo utilizando el método configurado.',
+];
index 81cec2b8151387b57221171c2c525c8a2dd0b977..6320d340ac5efe8c25a5afe3d389769a5bece3b8 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rol',
     'cover_image' => 'Imagen de portada',
     'cover_image_description' => 'Esta imagen debe ser aproximadamente de 440x250px.',
-    
+
     // Actions
     'actions' => 'Acciones',
     'view' => 'Ver',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Resetear',
     'remove' => 'Remover',
     'add' => 'Añadir',
+    'configure' => 'Configurar',
     'fullscreen' => 'Pantalla completa',
+    'favourite' => 'Añadir a favoritos',
+    'unfavourite' => 'Eliminar de favoritos',
+    'next' => 'Siguiente',
+    'previous' => 'Anterior',
+    'filter_active' => 'Filtro activo:',
+    'filter_clear' => 'Limpiar filtro',
 
     // Sort Options
     'sort_options' => 'Opciones de ordenación',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Ninguna actividad para mostrar',
     'no_items' => 'No hay elementos disponibles',
     'back_to_top' => 'Volver arriba',
+    'skip_to_main_content' => 'Ir al contenido principal',
     'toggle_details' => 'Alternar detalles',
     'toggle_thumbnails' => 'Alternar miniaturas',
     'details' => 'Detalles',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Vista en Lista',
     'default' => 'Predeterminada',
     'breadcrumb' => 'Rastro de migas de pan',
+    'status' => 'Estado',
+    'status_active' => 'Activo',
+    'status_inactive' => 'Inactive',
+    'never' => 'Nunca',
+    'none' => 'Ninguno',
 
     // Header
     'header_menu_expand' => 'Expandir el Menú de la Cabecera',
index 714f5b92890ae329ad9d216c6e6e593c376e33ab..6092c567f6cc40155e569da0204067cf665ca6d9 100644 (file)
@@ -6,9 +6,9 @@
 return [
 
     // Shared
-    'recently_created' => 'Recientemente creado',
-    'recently_created_pages' => 'Páginas recientemente creadas',
-    'recently_updated_pages' => 'Páginas recientemente actualizadas',
+    'recently_created' => 'Creado Recientemente',
+    'recently_created_pages' => 'Páginas creadas recientemente',
+    'recently_updated_pages' => 'Páginas actualizadas recientemente',
     'recently_created_chapters' => 'Capítulos recientemente creados',
     'recently_created_books' => 'Libros recientemente creados',
     'recently_created_shelves' => 'Estantes recientemente creados',
@@ -27,6 +27,8 @@ return [
     'images' => 'Imágenes',
     'my_recent_drafts' => 'Mis borradores recientes',
     'my_recently_viewed' => 'Mis visualizaciones recientes',
+    'my_most_viewed_favourites' => 'Mis favoritos más vistos',
+    'my_favourites' => 'Mis favoritos',
     'no_pages_viewed' => 'No ha visto ninguna página',
     'no_pages_recently_created' => 'Ninguna página ha sido creada recientemente',
     'no_pages_recently_updated' => 'Ninguna página ha sido actualizada recientemente',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Archivo web',
     'export_pdf' => 'Archivo PDF',
     'export_text' => 'Archivo de texto',
+    'export_md' => 'Archivo Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permisos del estante',
     'shelves_permissions_updated' => 'Permisos del estante actualizados',
     'shelves_permissions_active' => 'Permisos del estante activos',
+    'shelves_permissions_cascade_warning' => 'Los permisos en los estantes no se aplican automáticamente a los libros contenidos. Esto se debe a que un libro puede existir en múltiples estantes. Sin embargo, los permisos pueden ser aplicados a los libros del estante utilizando la opción a continuación.',
     'shelves_copy_permissions_to_books' => 'Copiar permisos a los libros',
     'shelves_copy_permissions' => 'Copiar permisos',
     'shelves_copy_permissions_explain' => 'Esto aplicará los ajustes de permisos de este estante para todos sus libros. Antes de activarlo, asegúrese de que todos los cambios de permisos para este estante han sido guardados.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Capítulos al final ',
     'books_sort_show_other' => 'Mostrar otros libros',
     'books_sort_save' => 'Guardar nuevo orden',
+    'books_copy' => 'Copiar Libro',
+    'books_copy_success' => 'Libro copiado correctamente',
 
     // Chapters
     'chapter' => 'Capítulo',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Mover capítulo',
     'chapters_move_named' => 'Mover Capítulo :chapterName',
     'chapter_move_success' => 'Capítulo movido a :bookName',
+    'chapters_copy' => 'Copiar Capítulo',
+    'chapters_copy_success' => 'Capítulo copiado correctamente',
     'chapters_permissions' => 'Permisos de capítulo',
     'chapters_empty' => 'No existen páginas en este capítulo.',
     'chapters_permissions_active' => 'Permisos de capítulo activos',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Página nueva',
     'pages_editing_draft_notification' => 'Está actualmente editando un borrador que fue guardado por última vez el :timeDiff.',
     'pages_draft_edited_notification' => 'Esta página ha sido actualizada desde ese momento. Se recomienda que cancele este borrador.',
+    'pages_draft_page_changed_since_creation' => 'Esta página ha sido actualizada desde que se creó este borrador. Se recomienda descartar este borrador o tener cuidado de no sobrescribir ningún cambio en la página.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuarios han comenzado a editar esta página',
         'start_b' => ':userName ha comenzado a editar esta página',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Agrege algunas etiquetas para mejorar la categorización de su contenido. \n Puede asignar un valor a una etiqueta para una organización a mayor detalle.",
     'tags_add' => 'Agregar otra etiqueta',
     'tags_remove' => 'Eliminar esta etiqueta',
+    'tags_usages' => 'Uso total de etiquetas',
+    'tags_assigned_pages' => 'Asignadas a páginas',
+    'tags_assigned_chapters' => 'Asignadas a capitulos',
+    'tags_assigned_books' => 'Asignadas a libros',
+    'tags_assigned_shelves' => 'Asignadas a estantes',
+    'tags_x_unique_values' => ':count valores únicos',
+    'tags_all_values' => 'Todos los valores',
+    'tags_view_tags' => 'Ver etiquetas',
+    'tags_view_existing_tags' => 'Ver etiquetas existentes',
+    'tags_list_empty_hint' => 'Las etiquetas se pueden asignar a través de la barra lateral del editor de páginas o mientras se editan los detalles de un libro, capítulo o estante.',
     'attachments' => 'Adjuntos',
     'attachments_explain' => 'Subir ficheros o agregar enlaces para mostrar en la página. Estos son visibles en la barra lateral de la página.',
     'attachments_explain_instant_save' => 'Los cambios son guardados de manera instantánea .',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => '¿Está seguro de que desea eliminar esta revisión?',
     'revision_restore_confirm' => '¿Está seguro de que desea restaurar esta revisión? El contenido actual de la página será reemplazado.',
     'revision_delete_success' => 'Revisión eliminada',
-    'revision_cannot_delete_latest' => 'No se puede eliminar la última revisión.'
+    'revision_cannot_delete_latest' => 'No se puede eliminar la última revisión.',
+
+    // Copy view
+    'copy_consider' => 'Por favor, tenga en cuenta lo siguiente al copiar el contenido.',
+    'copy_consider_permissions' => 'Los ajustes de permisos personalizados no serán copiados.',
+    'copy_consider_owner' => 'Usted se convertirá en el dueño de todo el contenido copiado.',
+    'copy_consider_images' => 'Los archivos de imagen de de las páginas no serán duplicados y las imágenes originales conservarán su relación con la página a la que fueron subidos originalmente.',
+    'copy_consider_attachments' => 'Los archivos adjuntos de la página no serán copiados.',
+    'copy_consider_access' => 'Un cambio de ubicación, propietario o permisos puede resultar en que este contenido sea accesible para aquellos que anteriormente no tuvieran acceso.',
 ];
index f83ec4b80d77755158d06acbd5dc5d8cf2c25681..05357421f8f856875e0d05a3643487b3afb4c9da 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
     'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.',
     'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
+    'oidc_already_logged_in' => 'Ya tenías la sesión iniciada',
+    'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado',
+    'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
+    'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
     'social_no_action_defined' => 'Acción no definida',
     'social_login_bad_response' => "Se ha recibido un error durante el acceso con :socialAccount error: \n:error",
     'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente acceder a través de la opción :socialAccount .',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Página no encontrada',
     'sorry_page_not_found' => 'Lo sentimos, la página a la que intenta acceder no pudo ser encontrada.',
     'sorry_page_not_found_permission_warning' => 'Si esperaba que esta página existiera, puede que no tenga permiso para verla.',
+    'image_not_found' => 'Imagen no encontrada',
+    'image_not_found_subtitle' => 'Lo sentimos, no se pudo encontrar el archivo de imagen que estaba buscando.',
+    'image_not_found_details' => 'Si esperaba que esta imagen existiera, podría haber sido eliminada.',
     'return_home' => 'Volver a la página de inicio',
     'error_occurred' => 'Ha ocurrido un error',
     'app_down' => 'La aplicación :appName se encuentra caída en este momento',
index d6a208b8c1da2aa261816ee2f29776b5466955a6..16fc01b8f535823206c78fe777f3099a8018f978 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Mantenimiento',
     'maint_image_cleanup' => 'Limpiar imágenes',
-    'maint_image_cleanup_desc' => "Analiza las páginas y sus revisiones para comprobar qué imágenes y dibujos están siendo utilizadas y cuales no son necesarias. Asegúrate de crear una copia completa de la base de datos y de las imágenes antes de lanzar esta opción.",
+    'maint_image_cleanup_desc' => 'Analiza las páginas y sus revisiones para comprobar qué imágenes y dibujos están siendo utilizadas y cuales no son necesarias. Asegúrate de crear una copia completa de la base de datos y de las imágenes antes de lanzar esta opción.',
     'maint_delete_images_only_in_revisions' => 'Elimina también imágenes que sólo existen en antiguas revisiones de páginas',
     'maint_image_cleanup_run' => 'Lanzar limpieza',
     'maint_image_cleanup_warning' => 'Se han encontrado :count imágenes posiblemente no utilizadas . ¿Estás seguro de querer borrar estas imágenes?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Papelera de Reciclaje',
     'recycle_bin_desc' => 'Aquí puede restaurar elementos que hayan sido eliminados o elegir eliminarlos permanentemente del sistema. Esta lista no está filtrada a diferencia de las listas de actividad similares en el sistema donde se aplican los filtros de permisos.',
     'recycle_bin_deleted_item' => 'Elemento Eliminado',
+    'recycle_bin_deleted_parent' => 'Superior',
     'recycle_bin_deleted_by' => 'Eliminado por',
     'recycle_bin_deleted_at' => 'Fecha de eliminación',
     'recycle_bin_permanently_delete' => 'Eliminar permanentemente',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elementos a restaurar',
     'recycle_bin_restore_confirm' => 'Esta acción restaurará el elemento eliminado, incluyendo cualquier elemento secundario, a su ubicación original. Si la ubicación original ha sido eliminada, y ahora está en la papelera de reciclaje, el elemento padre también tendrá que ser restaurado.',
     'recycle_bin_restore_deleted_parent' => 'El padre de este elemento también ha sido eliminado. Estos permanecerán eliminados hasta que el padre también sea restaurado.',
+    'recycle_bin_restore_parent' => 'Restaurar Superior',
     'recycle_bin_destroy_notification' => 'Eliminados :count artículos de la papelera de reciclaje.',
     'recycle_bin_restore_notification' => 'Restaurados :count artículos desde la papelera de reciclaje.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Usuario',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Elemento o detalle relacionados',
+    'audit_table_ip' => 'Dirección IP',
     'audit_table_date' => 'Fecha de la actividad',
     'audit_date_from' => 'Rango de fecha desde',
     'audit_date_to' => 'Rango de fecha hasta',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Detalles de rol',
     'role_name' => 'Nombre de rol',
     'role_desc' => 'Descripción corta de rol',
+    'role_mfa_enforced' => 'Requiere Autenticación en Dos Pasos',
     'role_external_auth_id' => 'ID externo de autenticación',
     'role_system' => 'Permisos de sistema',
     'role_manage_users' => 'Gestionar usuarios',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Administrar plantillas',
     'role_access_api' => 'API de sistema de acceso',
     'role_manage_settings' => 'Gestionar ajustes de la aplicación',
+    'role_export_content' => 'Exportar contenido',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Roles de usuario',
     'users_role_desc' => 'Selecciona los roles a los que será asignado este usuario. Si se asignan varios roles los permisos se acumularán y recibirá todas las habilidades de los roles asignados.',
     'users_password' => 'Contraseña de Usuario',
-    'users_password_desc' => 'Ajusta una contraseña que se utilizará para acceder a la aplicación. Debe ser al menos de 5 caracteres de longitud.',
+    'users_password_desc' => 'Establezca una contraseña para iniciar sesión en la aplicación. Debe tener al menos 8 caracteres.',
     'users_send_invite_text' => 'Puede enviar una invitación a este usuario por correo electrónico que le permitirá ajustar su propia contraseña, o puede usted ajustar su contraseña.',
     'users_send_invite_option' => 'Enviar un correo electrónico de invitación',
     'users_external_auth_id' => 'ID externo de autenticación',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Crear token',
     'users_api_tokens_expires' => 'Expira',
     'users_api_tokens_docs' => 'Documentación API',
+    'users_mfa' => 'Autenticación en Dos Pasos',
+    'users_mfa_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta.',
+    'users_mfa_x_methods' => ':count método configurado|:count métodos configurados',
+    'users_mfa_configure' => 'Configurar Métodos',
 
     // API Tokens
     'user_api_token_create' => 'Crear token API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => '¿Está seguro de que desea borrar este API token?',
     'user_api_token_delete_success' => 'Token API borrado correctamente',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Crear Webhook',
+    'webhooks_none_created' => 'No hay webhooks creados.',
+    'webhooks_edit' => 'Editar Webhook',
+    'webhooks_save' => 'Guardar Webhook',
+    'webhooks_details' => 'Detalles del Webhook',
+    'webhooks_details_desc' => 'Proporcione un nombre y un punto final POST como destino para los datos del webhook que se enviarán.',
+    'webhooks_events' => 'Eventos del Webhook',
+    'webhooks_events_desc' => 'Seleccione todos los eventos que deberían activar este webhook.',
+    'webhooks_events_warning' => 'Tenga en cuenta que estos eventos se activarán para todos los eventos seleccionados, incluso si se aplican permisos personalizados. Asegúrese de que el uso de este webhook no exponga contenido confidencial.',
+    'webhooks_events_all' => 'Todos los eventos del sistema',
+    'webhooks_name' => 'Nombre del Webhook',
+    'webhooks_timeout' => 'Tiempo de Espera de Webhook (Segundos)',
+    'webhooks_endpoint' => 'Punto final del Webhook',
+    'webhooks_active' => 'Webhook Activo',
+    'webhook_events_table_header' => 'Eventos',
+    'webhooks_delete' => 'Eliminar Webhook',
+    'webhooks_delete_warning' => 'Esto eliminará completamente este webhook, con el nombre \':webhookName\', del sistema.',
+    'webhooks_delete_confirm' => '¿Seguro que quieres eliminar este webhook?',
+    'webhooks_format_example' => 'Ejemplo de Formato de Webhook',
+    'webhooks_format_example_desc' => 'Los datos del Webhook se envían como una solicitud POST al punto final configurado como JSON siguiendo el formato mostrado a continuación. Las propiedades "related_item" y "url" son opcionales y dependerán del tipo de evento activado.',
+    'webhooks_status' => 'Estado del Webhook',
+    'webhooks_last_called' => 'Última Ejecución:',
+    'webhooks_last_errored' => 'Último error:',
+    'webhooks_last_error_message' => 'Último mensaje de error:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 450e923753b001d77f58a84a31a33c56c991dcf0..177eb812c47d5cff9eb84e147a3143100f05aad5 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'El :attribute solo puede contener letras, números y guiones.',
     'alpha_num'            => 'El :attribute solo puede contener letras y números.',
     'array'                => 'El :attribute debe de ser un array.',
+    'backup_codes'         => 'El código suministrado no es válido o ya ha sido utilizado.',
     'before'               => 'El :attribute debe ser una fecha anterior a  :date.',
     'between'              => [
         'numeric' => 'El :attribute debe estar entre :min y :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'El atributo :attribute debe ser una cadena de texto.',
     'timezone'             => 'El atributo :attribute debe ser una zona válida.',
+    'totp'                 => 'El código suministrado no es válido o ya ha expirado.',
     'unique'               => 'El atributo :attribute ya ha sido tomado.',
     'url'                  => 'El atributo :attribute tiene un formato inválido.',
     'uploaded'             => 'El archivo no ha podido subirse. Es posible que el servidor no acepte archivos de este tamaño.',
index 7c9e22450756469b593720860fe374a5542df9fc..0832f8d0ba6e1b8c72fdec70224c64a788cb8ca2 100644 (file)
@@ -6,42 +6,58 @@
 return [
 
     // Pages
-    'page_create'                 => 'página creada',
-    'page_create_notification'    => 'Página creada exitosamente',
+    'page_create'                 => 'creó la página',
+    'page_create_notification'    => 'Página creada correctamente',
     'page_update'                 => 'página actualizada',
-    'page_update_notification'    => 'Página actualizada exitosamente',
+    'page_update_notification'    => 'Página actualizada correctamente',
     'page_delete'                 => 'página borrada',
-    'page_delete_notification'    => 'Página borrada exitosamente',
+    'page_delete_notification'    => 'Página eliminada correctamente',
     'page_restore'                => 'página restaurada',
-    'page_restore_notification'   => 'Página restaurada exitosamente',
+    'page_restore_notification'   => 'Página restaurada correctamente',
     'page_move'                   => 'página movida',
 
     // Chapters
     'chapter_create'              => 'capítulo creado',
-    'chapter_create_notification' => 'Capítulo creado exitosamente',
+    'chapter_create_notification' => 'Capítulo creado correctamente',
     'chapter_update'              => 'capítulo actualizado',
-    'chapter_update_notification' => 'Capítulo actualizado exitosamente',
+    'chapter_update_notification' => 'Capítulo actualizado correctamente',
     'chapter_delete'              => 'capítulo borrado',
-    'chapter_delete_notification' => 'Capítulo borrado exitosamente',
+    'chapter_delete_notification' => 'Capítulo eliminado correctamente',
     'chapter_move'                => 'capítulo movido',
 
     // Books
     'book_create'                 => 'libro creado',
-    'book_create_notification'    => 'Libro creado exitosamente',
+    'book_create_notification'    => 'Libro creado correctamente',
     'book_update'                 => 'libro actualizado',
-    'book_update_notification'    => 'Libro actualizado exitosamente',
+    'book_update_notification'    => 'Libro actualizado correctamente',
     'book_delete'                 => 'libro borrado',
-    'book_delete_notification'    => 'Libro borrado exitosamente',
+    'book_delete_notification'    => 'Libro eliminado correctamente',
     'book_sort'                   => 'libro ordenado',
-    'book_sort_notification'      => 'Libro reordenado exitosamente',
+    'book_sort_notification'      => 'Libro reordenado correctamente',
 
     // Bookshelves
-    'bookshelf_create'            => 'Estante creado',
-    'bookshelf_create_notification'    => 'Estante creado exitosamente',
+    'bookshelf_create'            => 'estante creado',
+    'bookshelf_create_notification'    => 'Estante creado correctamente',
     'bookshelf_update'                 => 'Estante actualizado',
-    'bookshelf_update_notification'    => 'Estante actualizado exitosamente',
+    'bookshelf_update_notification'    => 'Estante actualizado correctamente',
     'bookshelf_delete'                 => 'Estante borrado',
-    'bookshelf_delete_notification'    => 'Estante borrado exitosamente',
+    'bookshelf_delete_notification'    => 'Estante eliminado correctamente',
+
+    // Favourites
+    'favourite_add_notification' => '".name" se añadió a sus favoritos',
+    'favourite_remove_notification' => '".name" se eliminó de sus favoritos',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Método de autenticación de múltiples factores configurado satisfactoriamente',
+    'mfa_remove_method_notification' => 'Método de autenticación de múltiples factores eliminado satisfactoriamente',
+
+    // Webhooks
+    'webhook_create' => 'webhook creado',
+    'webhook_create_notification' => 'Webhook creado correctamente',
+    'webhook_update' => 'webhook actualizado',
+    'webhook_update_notification' => 'Webhook actualizado correctamente',
+    'webhook_delete' => 'webhook eliminado',
+    'webhook_delete_notification' => 'Webhook eliminado correctamente',
 
     // Other
     'commented_on'                => 'comentado',
index 2f957f46d6f18d108cc5453f4b8faec60483efd4..3717063572fa89c8004523a9eed4dfc388898283 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Correo electrónico',
     'password' => 'Contraseña',
     'password_confirm' => 'Confirmar contraseña',
-    'password_hint' => 'Debe contener al menos 7 caracteres',
+    'password_hint' => 'Debe contener al menos 8 caracteres',
     'forgot_password' => '¿Olvidó la contraseña?',
     'remember_me' => 'Recordarme',
     'ldap_email_hint' => 'Por favor introduzca un correo electrónico para utilizar con esta cuenta.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Este dominio de correo electrónico no tiene acceso a esta aplicación',
     'register_success' => '¡Gracias por registrarse! Ahora se encuentra registrado y ha accedido a la aplicación.',
 
-
     // Password Reset
     'reset_password' => 'Restablecer la contraseña',
     'reset_password_send_instructions' => 'Introduzca su correo electrónico a continuación y se le enviará un correo electrónico con un enlace para la restauración',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Ud. esta recibiendo este correo electrónico debido a que recibimos una solicitud de restauración de la contraseña de su cuenta.',
     'email_reset_not_requested' => 'Si ud. no solicitó un cambio de contraseña, no se requiere ninguna acción.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirme su correo electrónico en :appName',
     'email_confirm_greeting' => '¡Gracias por unirse a :appName!',
     'email_confirm_text' => 'Por favor confirme su dirección de correo electrónico presionando en el siguiente botón:',
     'email_confirm_action' => 'Confirmar correo electrónico',
     'email_confirm_send_error' => 'Se pidió confirmación de correo electrónico pero el sistema no pudo enviar el correo electrónico. Contacte al administrador para asegurarse que el correo electrónico está configurado correctamente.',
-    'email_confirm_success' => '¡Su correo electrónico hasido confirmado!',
+    'email_confirm_success' => '¡Su correo electrónico ha sido confirmado! Ahora debería poder iniciar sesión usando esta dirección de correo electrónico.',
     'email_confirm_resent' => 'Correo electrónico de confirmación reenviado, Por favor verifique su bandeja de entrada.',
 
     'email_not_confirmed' => 'Dirección de correo electrónico no confirmada',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Bienvenido a :appName!',
     'user_invite_page_text' => 'Para finalizar la cuenta y tener acceso debe establcer una contraseña que utilizará para ingresar a :appName en visitas futuras.',
     'user_invite_page_confirm_button' => 'Confirmar Contraseña',
-    'user_invite_success' => 'Contraseña establecida, ahora tiene acceso a :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Contraseña guardada, ¡ahora debería ser capaz de iniciar sesión usando su contraseña establecida para acceder a :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Configurar autenticación de múltiples factores',
+    'mfa_setup_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.',
+    'mfa_setup_configured' => 'Ya está configurado',
+    'mfa_setup_reconfigure' => 'Reconfigurar',
+    'mfa_setup_remove_confirmation' => '¿Está seguro que desea eliminar este método de autenticación de múltiples factores?',
+    'mfa_setup_action' => 'Configuración',
+    'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genere y guarde un nuevo conjunto antes de que se quede sin códigos para evitar que se bloquee su cuenta.',
+    'mfa_option_totp_title' => 'Aplicación móvil',
+    'mfa_option_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Códigos de Respaldo',
+    'mfa_option_backup_codes_desc' => 'Almacene de forma segura un conjunto de códigos de respaldo de un solo uso que pueda introducir para verificar su identidad.',
+    'mfa_gen_confirm_and_enable' => 'Confirmar y Activar',
+    'mfa_gen_backup_codes_title' => 'Configuración de Códigos de Respaldo',
+    'mfa_gen_backup_codes_desc' => 'Guarde la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrá usar uno de los códigos como un segundo mecanismo de autenticación.',
+    'mfa_gen_backup_codes_download' => 'Descargar Códigos',
+    'mfa_gen_backup_codes_usage_warning' => 'Cada código puede utilizarse sólo una vez',
+    'mfa_gen_totp_title' => 'Configuración de Aplicación móvil',
+    'mfa_gen_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Escanea el código QR mostrado a continuación usando tu aplicación de autenticación preferida para empezar.',
+    'mfa_gen_totp_verify_setup' => 'Verificar Configuración',
+    'mfa_gen_totp_verify_setup_desc' => 'Verifica que todo está funcionando introduciendo un código, generado en tu aplicación de autenticación, en el campo de texto a continuación:',
+    'mfa_gen_totp_provide_code_here' => 'Introduce aquí tu código generado por la aplicación',
+    'mfa_verify_access' => 'Verificar Acceso',
+    'mfa_verify_access_desc' => 'Su cuenta de usuario requiere que confirme su identidad a través de un nivel adicional de verificación antes de que se le conceda el acceso. Verifique su identidad usando uno de los métodos configurados para continuar.',
+    'mfa_verify_no_methods' => 'No hay Métodos Configurados',
+    'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación de múltiples factores para su cuenta. Tendrá que configurar al menos un método antes de obtener acceso.',
+    'mfa_verify_use_totp' => 'Verificar usando una aplicación móvil',
+    'mfa_verify_use_backup_codes' => 'Verificar usando un código de respaldo',
+    'mfa_verify_backup_code' => 'Código de Respaldo',
+    'mfa_verify_backup_code_desc' => 'Introduzca uno de sus códigos de respaldo restantes a continuación:',
+    'mfa_verify_backup_code_enter_here' => 'Introduzca el código de respaldo aquí',
+    'mfa_verify_totp_desc' => 'A continuación, introduzca el código generado con su aplicación móvil:',
+    'mfa_setup_login_notification' => 'Método de dos factores configurado. Por favor, inicie sesión nuevamente utilizando el método configurado.',
+];
index 495a338726ca5baed6d92ff7cfb317d992d8a54a..2b681f961c48b9faff9c918688e0f8ce8a58ea55 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rol',
     'cover_image' => 'Imagen de cubierta',
     'cover_image_description' => 'Esta imagen debe ser de 440x250px aproximadamente.',
-    
+
     // Actions
     'actions' => 'Acciones',
     'view' => 'Ver',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Restablecer',
     'remove' => 'Remover',
     'add' => 'Agregar',
+    'configure' => 'Configurar',
     'fullscreen' => 'Pantalla completa',
+    'favourite' => 'Favoritos',
+    'unfavourite' => 'Eliminar de favoritos',
+    'next' => 'Siguiente',
+    'previous' => 'Anterior',
+    'filter_active' => 'Filtro activo:',
+    'filter_clear' => 'Limpiar filtro',
 
     // Sort Options
     'sort_options' => 'Opciones de Orden',
@@ -54,8 +61,9 @@ return [
     // Misc
     'deleted_user' => 'Usuario borrado',
     'no_activity' => 'Ninguna actividad para mostrar',
-    'no_items' => 'No hay items disponibles',
+    'no_items' => 'No hay elementos disponibles',
     'back_to_top' => 'Volver arriba',
+    'skip_to_main_content' => 'Ir al contenido principal',
     'toggle_details' => 'Alternar detalles',
     'toggle_thumbnails' => 'Alternar miniaturas',
     'details' => 'Detalles',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Vista de lista',
     'default' => 'Por defecto',
     'breadcrumb' => 'Miga de Pan',
+    'status' => 'Estado',
+    'status_active' => 'Activo',
+    'status_inactive' => 'Inactivo',
+    'never' => 'Nunca',
+    'none' => 'Ninguno',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Expandir el Menú de Cabecera',
     'profile_menu' => 'Menu del Perfil',
     'view_profile' => 'Ver Perfil',
     'edit_profile' => 'Editar Perfil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Información',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Pestaña: Mostrar Información Secundaria',
     'tab_content' => 'Contenido',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Pestaña: Mostrar Contenido Primario',
 
     // Email Content
     'email_action_help' => 'Si está teniendo problemas haga click en el botón ":actionText", copie y pegue la siguiente URL en su navegador web:',
index 52bd61f9e8d39cf174f9e5f16cc1efcf1878e428..526f660deee75bd82fe868aaad611466622922e5 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Imágenes',
     'my_recent_drafts' => 'Mis borradores recientes',
     'my_recently_viewed' => 'Mis visualizaciones recientes',
+    'my_most_viewed_favourites' => 'Mis Favoritos Más Vistos',
+    'my_favourites' => 'Mis Favoritos',
     'no_pages_viewed' => 'Ud. no ha visto ninguna página',
     'no_pages_recently_created' => 'Ninguna página ha sido creada recientemente',
     'no_pages_recently_updated' => 'Ninguna página ha sido actualizada recientemente',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Archivo web contenido',
     'export_pdf' => 'Archivo PDF',
     'export_text' => 'Archivo de texto plano',
+    'export_md' => 'Archivo Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permisos del Estante',
     'shelves_permissions_updated' => 'Permisos del Estante actualizados',
     'shelves_permissions_active' => 'Permisos Activos del Estante',
+    'shelves_permissions_cascade_warning' => 'Los permisos en los estantes no se aplican automáticamente a los libros contenidos. Esto se debe a que un libro puede existir en múltiples estantes. Sin embargo, los permisos pueden ser aplicados a los libros del estante utilizando la opción a continuación.',
     'shelves_copy_permissions_to_books' => 'Copiar Permisos a los Libros',
     'shelves_copy_permissions' => 'Copiar Permisos',
     'shelves_copy_permissions_explain' => 'Esta acción aplicará los permisos de este estante a todos los libros contenidos en él. Antes de activarlos, asegúrese que los cambios a los permisos de este estante estén guardados.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Capítulos al final',
     'books_sort_show_other' => 'Mostrar otros libros',
     'books_sort_save' => 'Guardar nuevo orden',
+    'books_copy' => 'Copiar Libro',
+    'books_copy_success' => 'Libro copiado correctamente',
 
     // Chapters
     'chapter' => 'Capítulo',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Mover capítulo',
     'chapters_move_named' => 'Mover Capítulo :chapterName',
     'chapter_move_success' => 'Capítulo movido a :bookName',
+    'chapters_copy' => 'Copiar Capítulo',
+    'chapters_copy_success' => 'Capítulo copiado correctamente',
     'chapters_permissions' => 'Permisos de capítulo',
     'chapters_empty' => 'No existen páginas en este capítulo.',
     'chapters_permissions_active' => 'Permisos de capítulo activado',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Página nueva',
     'pages_editing_draft_notification' => 'Usted está actualmente editando un borrador que fue guardado por última vez el :timeDiff.',
     'pages_draft_edited_notification' => 'Esta página ha sido actualizada desde aquel momento. Se recomienda que cancele este borrador.',
+    'pages_draft_page_changed_since_creation' => 'Esta página fue actualizada desde que se creó este borrador. Se recomienda descartar este borrador o tener cuidado de no sobrescribir ningún cambio en la página.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuarios han comenzado a editar esta página',
         'start_b' => ':userName ha comenzado a editar esta página',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Agregar algunas etiquetas para mejorar la categorización de su contenido. \n Se puede asignar un valor a una etiqueta para una organizacón con mayor detalle.",
     'tags_add' => 'Agregar otra etiqueta',
     'tags_remove' => 'Eliminar esta etiqueta',
+    'tags_usages' => 'Uso total de etiquetas',
+    'tags_assigned_pages' => 'Asignadas a páginas',
+    'tags_assigned_chapters' => 'Asignadas a capítulos',
+    'tags_assigned_books' => 'Asignadas a libros',
+    'tags_assigned_shelves' => 'Asignadas a estantes',
+    'tags_x_unique_values' => ':count valores únicos',
+    'tags_all_values' => 'Todos los valores',
+    'tags_view_tags' => 'Ver etiquetas',
+    'tags_view_existing_tags' => 'Ver etiquetas existentes',
+    'tags_list_empty_hint' => 'Las etiquetas se pueden asignar a través de la barra lateral del editor de páginas o mientras se editan los detalles de un libro, capítulo o estante.',
     'attachments' => 'Adjuntos',
     'attachments_explain' => 'Subir archivos o agregar enlaces para mostrar en la página. Estos son visibles en la barra lateral de la página.',
     'attachments_explain_instant_save' => 'Los cambios se guardan de manera instantánea.',
@@ -281,7 +300,7 @@ return [
     'attachments_link_attached' => 'Enlace agregado exitosamente a la página',
     'templates' => 'Plantillas',
     'templates_set_as_template' => 'La Página es una plantilla',
-    'templates_explain_set_as_template' => 'Puede establecer esta página como plantilla para que el contenido pueda utilizarse para al crear otras páginas. Otris usuarios podrán utilizar esta plantilla si tienen permisos para ver de esta página.',
+    'templates_explain_set_as_template' => 'Puede establecer esta página como plantilla para que el contenido pueda utilizarse al crear otras páginas. Otros usuarios podrán utilizar esta plantilla si tienen permisos para ver de esta página.',
     'templates_replace_content' => 'Reemplazar el contenido de la página',
     'templates_append_content' => 'Incorporar al fina del contenido de la página',
     'templates_prepend_content' => 'Incorporar al principio del contenido de la página',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => '¿Está seguro de que quiere eliminar esta revisión?',
     'revision_restore_confirm' => '¿Está seguro de que quiere restaurar esta revisión? Se reemplazará el contenido de la página actual.',
     'revision_delete_success' => 'Revisión eliminada',
-    'revision_cannot_delete_latest' => 'No se puede eliminar la última revisión.'
+    'revision_cannot_delete_latest' => 'No se puede eliminar la última revisión.',
+
+    // Copy view
+    'copy_consider' => 'Por favor, tenga en cuenta lo siguiente al copiar el contenido.',
+    'copy_consider_permissions' => 'Los ajustes de permisos personalizados no serán copiados.',
+    'copy_consider_owner' => 'Usted se convertirá en el dueño de todo el contenido copiado.',
+    'copy_consider_images' => 'Los archivos de imagen de la página no serán duplicados y las imágenes originales conservarán su relación con la página a la que fueron subidos originalmente.',
+    'copy_consider_attachments' => 'Los archivos adjuntos de la página no serán copiados.',
+    'copy_consider_access' => 'Un cambio de ubicación, propietario o permisos puede resultar en que este contenido sea accesible para aquellos que anteriormente no tuvieran acceso.',
 ];
index 41719a5bcb3c035c553eada751bd4e93eebf8a48..7ded6705759a114c63594a1a5a798625c3b82722 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
     'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.',
     'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
+    'oidc_already_logged_in' => 'Ya está conectado',
+    'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado',
+    'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico para este usuario en los datos proporcionados por el sistema de autenticación externo',
+    'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
     'social_no_action_defined' => 'Acción no definida',
     'social_login_bad_response' => "SE recibió un Error durante el acceso con :socialAccount : \n:error",
     'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente loguearse a través de la opcón :socialAccount .',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Página no encontrada',
     'sorry_page_not_found' => 'Lo sentimos, la página que intenta acceder no pudo ser encontrada.',
     'sorry_page_not_found_permission_warning' => 'Si esperaba que esta página existiera, puede que no tenga permiso para verla.',
+    'image_not_found' => 'No se encuentra la imagen',
+    'image_not_found_subtitle' => 'Lo siento, no se pudo encontrar la imagen que busca.',
+    'image_not_found_details' => 'Si esperaba que esta imagen exista es probable que se haya eliminado.',
     'return_home' => 'Volver al home',
     'error_occurred' => 'Ha ocurrido un error',
     'app_down' => 'La aplicación :appName se encuentra caída en este momento',
index cf250d79e670373fc39a0875f6ae38199a4dae7a..c7bb2ddd6455c1717d4d6b63e4cedc4bc6f00cff 100644 (file)
@@ -31,7 +31,7 @@ return [
     'app_custom_html_desc' => 'Cualquier contenido agregado aquí será agregado al final de la sección <head> de cada página. Esto es útil para sobreescribir estilos o agregar código para analíticas.',
     'app_custom_html_disabled_notice' => 'El contenido personailzado para la cabecera HTML está deshabilitado en esta configuración para garantizar que cualquier cambio importante se pueda revertir.',
     'app_logo' => 'Logo de la aplicación',
-    'app_logo_desc' => 'Esta imagen debería ser de 43px en altura. <br>Las imágenes grandes seán escaladas.',
+    'app_logo_desc' => 'Esta imagen debería ser de 43px en altura. <br>Las imágenes grandes serán achicadas.',
     'app_primary_color' => 'Color primario de la aplicación',
     'app_primary_color_desc' => 'Esto debería ser un valor hexadecimal. <br>Deje el valor vacío para reiniciar al valor por defecto.',
     'app_homepage' => 'Página de inicio de la Aplicación',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Mantenimiento',
     'maint_image_cleanup' => 'Limpiar imágenes',
-    'maint_image_cleanup_desc' => "Analizar contenido de páginas y revisiones para detectar cuáles imágenes y dibujos están en uso y cuáles son redundantes. Asegúrese de crear un respaldo completo de imágenes y base de datos antes de ejecutar esta tarea.",
+    'maint_image_cleanup_desc' => 'Analizar contenido de páginas y revisiones para detectar cuáles imágenes y dibujos están en uso y cuáles son redundantes. Asegúrese de crear un respaldo completo de imágenes y base de datos antes de ejecutar esta tarea.',
     'maint_delete_images_only_in_revisions' => 'También elimina imágenes que sólo existen en antiguas revisiones de páginas',
     'maint_image_cleanup_run' => 'Ejecutar limpieza',
     'maint_image_cleanup_warning' => 'Se encontraron :count imágenes pontencialmente sin uso. Está seguro de que quiere eliminarlas?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Papelera de Reciclaje',
     'recycle_bin_desc' => 'Aquí puede restaurar elementos que hayan sido eliminados o elegir eliminarlos permanentemente del sistema. Esta lista no está filtrada a diferencia de las listas de actividad similares en el sistema donde se aplican los filtros de permisos.',
     'recycle_bin_deleted_item' => 'Elemento Eliminado',
+    'recycle_bin_deleted_parent' => 'Padre',
     'recycle_bin_deleted_by' => 'Eliminado por',
     'recycle_bin_deleted_at' => 'Fecha de eliminación',
     'recycle_bin_permanently_delete' => 'Eliminar permanentemente',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elementos a restaurar',
     'recycle_bin_restore_confirm' => 'Esta acción restaurará el elemento eliminado, incluyendo cualquier elemento secundario, a su ubicación original. Si la ubicación original ha sido eliminada, y ahora está en la papelera de reciclaje, el elemento padre también tendrá que ser restaurado.',
     'recycle_bin_restore_deleted_parent' => 'El padre de este elemento también ha sido eliminado. Estos permanecerán eliminados hasta que el padre también sea restaurado.',
+    'recycle_bin_restore_parent' => 'Restaurar Padre',
     'recycle_bin_destroy_notification' => 'Eliminados :count elementos de la papelera de reciclaje.',
     'recycle_bin_restore_notification' => 'Restaurados :count elementos desde la papelera de reciclaje.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Usuario',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Elemento o detalle relacionados',
+    'audit_table_ip' => 'Dirección IP',
     'audit_table_date' => 'Fecha de la Actividad',
     'audit_date_from' => 'Inicio del Rango de Fecha',
     'audit_date_to' => 'Final del Rango de Fecha',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Detalles de rol',
     'role_name' => 'Nombre de rol',
     'role_desc' => 'Descripción corta de rol',
+    'role_mfa_enforced' => 'Requiere autenticación de múltiples factores',
     'role_external_auth_id' => 'IDs de Autenticación Externa',
     'role_system' => 'Permisos de sistema',
     'role_manage_users' => 'Gestionar usuarios',
@@ -146,6 +150,7 @@ return [
     'role_manage_page_templates' => 'Gestionar las plantillas de páginas',
     'role_access_api' => 'API de sistema de acceso',
     'role_manage_settings' => 'Gestionar ajustes de activos',
+    'role_export_content' => 'Exportar contenido',
     '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.',
@@ -170,7 +175,7 @@ return [
     'users_role' => 'Roles de usuario',
     'users_role_desc' => 'Selecciona los roles a los que será asignado este usuario. Si se asignan varios roles los permisos se acumularán y recibirá todas las habilidades de los roles asignados.',
     'users_password' => 'Contraseña de Usuario',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 5 characters long.',
+    'users_password_desc' => 'Establezca una contraseña para iniciar sesión en la aplicación. Debe tener al menos 8 caracteres.',
     'users_send_invite_text' => 'Puede optar por enviar a este usuario un correo electrónico de invitación que les permita establecer su propia contraseña; de lo contrario, puede establecerla contraseña usted mismo.',
     'users_send_invite_option' => 'Enviar correo electrónico de invitación al usuario.',
     'users_external_auth_id' => 'ID externo de autenticación',
@@ -203,6 +208,10 @@ return [
     'users_api_tokens_create' => 'Crear token',
     'users_api_tokens_expires' => 'Expira',
     'users_api_tokens_docs' => 'Documentación API',
+    'users_mfa' => 'Autenticación de múltiples factores',
+    'users_mfa_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.',
+    'users_mfa_x_methods' => ':count método configurado|:count métodos configurados',
+    'users_mfa_configure' => 'Configurar Métodos',
 
     // API Tokens
     'user_api_token_create' => 'Crear token API',
@@ -225,6 +234,34 @@ return [
     'user_api_token_delete_confirm' => '¿Está seguro de que desea borrar este API token?',
     'user_api_token_delete_success' => 'Token API borrado correctamente',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Crear nuevo Webhook',
+    'webhooks_none_created' => 'No hay webhooks creados.',
+    'webhooks_edit' => 'Editar Webhook',
+    'webhooks_save' => 'Guardar Webhook',
+    'webhooks_details' => 'Detalles del Webhook',
+    'webhooks_details_desc' => 'Proporcione un nombre y un punto final de POST como destino para enviar los datos del webhook.',
+    'webhooks_events' => 'Eventos del Webhook',
+    'webhooks_events_desc' => 'Seleccione todos los eventos que deberían activar este webhook.',
+    'webhooks_events_warning' => 'Tenga en cuenta que estos eventos se activarán para todos los eventos seleccionados, incluso si se aplican permisos personalizados. Asegúrese de que el uso de este webhook no exponga contenido confidencial.',
+    'webhooks_events_all' => 'Todos los eventos del sistema',
+    'webhooks_name' => 'Nombre del Webhook',
+    'webhooks_timeout' => 'Tiempo de Espera de Solicitud del Webhook (Segundos)',
+    'webhooks_endpoint' => 'Punto final del Webhook',
+    'webhooks_active' => 'Webhook Activo',
+    'webhook_events_table_header' => 'Eventos',
+    'webhooks_delete' => 'Eliminar Webhook',
+    'webhooks_delete_warning' => 'Esto eliminará completamente del sistema este webhook con el nombre \':webhookName\'.',
+    'webhooks_delete_confirm' => '¿Está seguro que quiere eliminar este webhook?',
+    'webhooks_format_example' => 'Ejemplo de Formato de Webhook',
+    'webhooks_format_example_desc' => 'Los datos del Webhook, en formato JSON, se envían como una solicitud POST al punto final siguiendo el formato mostrado a continuación. Las propiedades "related_item" y "url" son opcionales y dependerán del tipo de evento activado.',
+    'webhooks_status' => 'Estado del Webhook',
+    'webhooks_last_called' => 'Última Ejecución:',
+    'webhooks_last_errored' => 'Último error:',
+    'webhooks_last_error_message' => 'Último mensaje de error:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -240,13 +277,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -262,6 +302,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index c3f5d83dd69c684752fb3cb7e8071d50ac43b9c6..6b72a65499d03c55c9982efd63fe5b93385422f6 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'El :attribute solo puede contener letras, números y guiones.',
     'alpha_num'            => 'El :attribute solo puede contener letras y número.',
     'array'                => 'El :attribute debe de ser un array.',
+    'backup_codes'         => 'El código suministrado no es válido o ya fue utilizado.',
     'before'               => 'El :attribute debe ser una fecha anterior a  :date.',
     'between'              => [
         'numeric' => 'El :attribute debe estar entre :min y :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'El atributo :attribute debe ser una cadena.',
     'timezone'             => 'El atributo :attribute debe ser una zona válida.',
+    'totp'                 => 'El código suministrado no es válido o ya expiró.',
     'unique'               => 'El atributo :attribute ya ha sido tomado.',
     'url'                  => 'El atributo :attribute tiene un formato inválido.',
     'uploaded'             => 'El archivo no se pudo subir. Puede ser que el servidor no acepte archivos de este tamaño.',
diff --git a/resources/lang/et/activities.php b/resources/lang/et/activities.php
new file mode 100644 (file)
index 0000000..26763c1
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'lisas lehe',
+    'page_create_notification'    => 'Leht on lisatud',
+    'page_update'                 => 'muutis lehte',
+    'page_update_notification'    => 'Leht on muudetud',
+    'page_delete'                 => 'kustutas lehe',
+    'page_delete_notification'    => 'Leht on kustutatud',
+    'page_restore'                => 'taastas lehe',
+    'page_restore_notification'   => 'Leht on taastatud',
+    'page_move'                   => 'liigutas lehte',
+
+    // Chapters
+    'chapter_create'              => 'lisas peatüki',
+    'chapter_create_notification' => 'Peatükk on lisatud',
+    'chapter_update'              => 'muutis peatükki',
+    'chapter_update_notification' => 'Peatükk on muudetud',
+    'chapter_delete'              => 'kustutas peatüki',
+    'chapter_delete_notification' => 'Peatükk on kustutatud',
+    'chapter_move'                => 'liigutas peatükki',
+
+    // Books
+    'book_create'                 => 'lisas raamatu',
+    'book_create_notification'    => 'Raamat on lisatud',
+    'book_update'                 => 'muutis raamatut',
+    'book_update_notification'    => 'Raamat on muudetud',
+    'book_delete'                 => 'kustutas raamatu',
+    'book_delete_notification'    => 'Raamat on kustutatud',
+    'book_sort'                   => 'sorteeris raamatut',
+    'book_sort_notification'      => 'Raamat on sorteeritud',
+
+    // Bookshelves
+    'bookshelf_create'            => 'lisas riiuli',
+    'bookshelf_create_notification'    => 'Riiul on lisatud',
+    'bookshelf_update'                 => 'muutis riiulit',
+    'bookshelf_update_notification'    => 'Riiul on muudetud',
+    'bookshelf_delete'                 => 'kustutas riiuli',
+    'bookshelf_delete_notification'    => 'Riiul on kustutatud',
+
+    // Favourites
+    'favourite_add_notification' => '":name" lisati su lemmikute hulka',
+    'favourite_remove_notification' => '":name" eemaldati su lemmikute hulgast',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Mitmeastmeline autentimine seadistatud',
+    'mfa_remove_method_notification' => 'Mitmeastmeline autentimine eemaldatud',
+
+    // Webhooks
+    'webhook_create' => 'lisas veebihaagi',
+    'webhook_create_notification' => 'Veebihaak on lisatud',
+    'webhook_update' => 'muutis veebihaaki',
+    'webhook_update_notification' => 'Veebihaak on muudetud',
+    'webhook_delete' => 'kustutas veebihaagi',
+    'webhook_delete_notification' => 'Veebihaak on kustutatud',
+
+    // Other
+    'commented_on'                => 'kommenteeris lehte',
+    'permissions_update'          => 'muutis õiguseid',
+];
diff --git a/resources/lang/et/auth.php b/resources/lang/et/auth.php
new file mode 100644 (file)
index 0000000..7a1817c
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Authentication Language Lines
+ * The following language lines are used during authentication for various
+ * messages that we need to display to the user.
+ */
+return [
+
+    'failed' => 'Kasutajanimi ja parool ei klapi.',
+    'throttle' => 'Liiga palju sisselogimiskatseid. Proovi uuesti :seconds sekundi pärast.',
+
+    // Login & Register
+    'sign_up' => 'Registreeru',
+    'log_in' => 'Logi sisse',
+    'log_in_with' => 'Logi sisse :socialDriver abil',
+    'sign_up_with' => 'Registreeru :socialDriver abil',
+    'logout' => 'Logi välja',
+
+    'name' => 'Nimi',
+    'username' => 'Kasutajanimi',
+    'email' => 'E-post',
+    'password' => 'Parool',
+    'password_confirm' => 'Kinnita parool',
+    'password_hint' => 'Peab olema vähemalt 8 tähemärki pikk',
+    'forgot_password' => 'Unustasid parooli?',
+    'remember_me' => 'Jäta mind meelde',
+    'ldap_email_hint' => 'Sisesta kasutajakonto e-posti aadress.',
+    'create_account' => 'Loo konto',
+    'already_have_account' => 'Kasutajakonto juba olemas?',
+    'dont_have_account' => 'Sul ei ole veel kontot?',
+    'social_login' => 'Sisene läbi sotsiaalmeedia',
+    'social_registration' => 'Registreeru läbi sotsiaalmeedia',
+    'social_registration_text' => 'Registreeru ja logi sisse välise teenuse kaudu.',
+
+    'register_thanks' => 'Aitäh, et registreerusid!',
+    'register_confirm' => 'Vaata oma postkasti ja klõpsa kinnitusnupul, et rakendusele :appName ligi pääseda.',
+    'registrations_disabled' => 'Registreerumine on hetkel keelatud',
+    'registration_email_domain_invalid' => 'Sellel e-posti domeenil ei ole rakendusele ligipääsu',
+    'register_success' => 'Aitäh, et registreerusid! Oled nüüd sisse logitud.',
+
+    // Password Reset
+    'reset_password' => 'Lähtesta parool',
+    'reset_password_send_instructions' => 'Siseta oma e-posti aadress ning sulle saadetakse link parooli lähtestamiseks.',
+    'reset_password_send_button' => 'Saada lähtestamise link',
+    'reset_password_sent' => 'Kui süsteemis leidub e-posti aadress :email, saadetakse sinna link parooli lähtestamiseks.',
+    'reset_password_success' => 'Sinu parool on edukalt lähtestatud.',
+    'email_reset_subject' => 'Lähtesta oma :appName parool',
+    'email_reset_text' => 'Said selle e-kirja, sest meile laekus soov sinu konto parooli lähtestamiseks.',
+    'email_reset_not_requested' => 'Kui sa ei soovinud parooli lähtestada, ei pea sa rohkem midagi tegema.',
+
+    // Email Confirmation
+    'email_confirm_subject' => 'Kinnita oma :appName konto e-posti aadress',
+    'email_confirm_greeting' => 'Aitäh, et liitusid rakendusega :appName!',
+    'email_confirm_text' => 'Palun kinnita oma e-posti aadress, klõpsates alloleval nupul:',
+    'email_confirm_action' => 'Kinnita e-posti aadress',
+    'email_confirm_send_error' => 'E-posti aadressi kinnitamine on vajalik, aga e-kirja saatmine ebaõnnestus. Võta ühendust administraatoriga.',
+    'email_confirm_success' => 'E-posti aadress on kinnitatud! Nüüd saad selle aadressiga sisse logida.',
+    'email_confirm_resent' => 'Kinnituskiri on saadetud, vaata oma postkasti.',
+
+    'email_not_confirmed' => 'E-posti aadress ei ole kinnitatud',
+    'email_not_confirmed_text' => 'Sinu e-posti aadress ei ole veel kinnitatud.',
+    'email_not_confirmed_click_link' => 'Klõpsa lingil e-kirjas, mis saadeti sulle pärast registreerumist.',
+    'email_not_confirmed_resend' => 'Kui sa ei leia e-kirja, siis saad alloleva vormi abil selle uuesti saata.',
+    'email_not_confirmed_resend_button' => 'Saada kinnituskiri uuesti',
+
+    // User Invite
+    'user_invite_email_subject' => 'Sind on kutsutud liituma rakendusega :appName!',
+    'user_invite_email_greeting' => 'Sulle on loodud kasutajakonto rakenduses :appName.',
+    'user_invite_email_text' => 'Vajuta allolevale nupule, et seada parool ja ligipääs saada:',
+    'user_invite_email_action' => 'Sea konto parool',
+    'user_invite_page_welcome' => 'Tere tulemast rakendusse :appName!',
+    'user_invite_page_text' => 'Registreerumise lõpetamiseks ja ligipääsu saamiseks pead seadma parooli, millega edaspidi rakendusse sisse logid.',
+    'user_invite_page_confirm_button' => 'Kinnita parool',
+    'user_invite_success_login' => 'Parool seatud, nüüd on sul selle parooli abil ligipääs rakendusele :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Seadista mitmeastmeline autentimine',
+    'mfa_setup_desc' => 'Seadista mitmeastmeline autentimine, et oma kasutajakonto turvalisust tõsta.',
+    'mfa_setup_configured' => 'Juba seadistatud',
+    'mfa_setup_reconfigure' => 'Seadista ümber',
+    'mfa_setup_remove_confirmation' => 'Kas oled kindel, et soovid selle mitmeastmelise autentimise meetodi eemaldada?',
+    'mfa_setup_action' => 'Seadista',
+    'mfa_backup_codes_usage_limit_warning' => 'Sul on vähem kui 5 varukoodi järel. Genereeri ja hoiusta uus komplekt enne, kui nad otsa saavad, et vältida oma kasutajakontole ligipääsu kaotamist.',
+    'mfa_option_totp_title' => 'Mobiilirakendus',
+    'mfa_option_totp_desc' => 'Mitmeastmelise autentimise kasutamiseks on sul vaja TOTP-toega mobiilirakendust, nagu Google Authenticator, Authy või Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Varukoodid',
+    'mfa_option_backup_codes_desc' => 'Hoiusta kindlas kohas komplekt ühekordseid varukoode, millega saad oma isikut tuvastada.',
+    'mfa_gen_confirm_and_enable' => 'Kinnita ja lülita sisse',
+    'mfa_gen_backup_codes_title' => 'Varukoodide seadistamine',
+    'mfa_gen_backup_codes_desc' => 'Hoiusta allolevad koodid turvalises kohas. Saad neid kasutada sisselogimisel sekundaarse autentimismeetodina.',
+    'mfa_gen_backup_codes_download' => 'Laadi koodid alla',
+    'mfa_gen_backup_codes_usage_warning' => 'Igat koodi saab ainult ühe korra kasutada',
+    'mfa_gen_totp_title' => 'Mobiilirakenduse seadistamine',
+    'mfa_gen_totp_desc' => 'Mitmeastmelise autentimise kasutamiseks on sul vaja TOTP-toega mobiilirakendust, nagu Google Authenticator, Authy või Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Alustamiseks skaneeri allolevat QR-koodi oma eelistatud rakendusega.',
+    'mfa_gen_totp_verify_setup' => 'Kontrolli seadistust',
+    'mfa_gen_totp_verify_setup_desc' => 'Veendu, et kõik toimib korrektselt, sisestades oma rakenduse genereeritud koodi allolevasse tekstikasti:',
+    'mfa_gen_totp_provide_code_here' => 'Sisesta rakenduse genereeritud kood siia',
+    'mfa_verify_access' => 'Kinnita ligipääs',
+    'mfa_verify_access_desc' => 'Sinu konto nõuab ligipääsuks täiendava kinnitusmeetodi abil oma isiku tuvastamist. Jätkamiseks vali üks järgnevatest meetoditest.',
+    'mfa_verify_no_methods' => 'Ühtegi meetodit pole seadistatud',
+    'mfa_verify_no_methods_desc' => 'Sinu kontole pole lisatud ühtegi mitmeastmelise autentimise meetodit. Ligipääsu saamiseks pead seadistama vähemalt ühe meetodi.',
+    'mfa_verify_use_totp' => 'Tuvasta mobiilirakendusega',
+    'mfa_verify_use_backup_codes' => 'Tuvasta varukoodiga',
+    'mfa_verify_backup_code' => 'Varukood',
+    'mfa_verify_backup_code_desc' => 'Sisesta allpool üks oma järelejäänud varukoodidest:',
+    'mfa_verify_backup_code_enter_here' => 'Sisesta varukood siia',
+    'mfa_verify_totp_desc' => 'Sisesta oma mobiilirakenduse poolt genereeritud kood allpool:',
+    'mfa_setup_login_notification' => 'Mitmeastmeline autentimine seadistatud. Logi nüüd uuesti sisse, kasutades seadistatud meetodit.',
+];
diff --git a/resources/lang/et/common.php b/resources/lang/et/common.php
new file mode 100644 (file)
index 0000000..6aeb8fb
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Tühista',
+    'confirm' => 'Kinnita',
+    'back' => 'Tagasi',
+    'save' => 'Salvesta',
+    'continue' => 'Jätka',
+    'select' => 'Vali',
+    'toggle_all' => 'Vaheta kõik',
+    'more' => 'Rohkem',
+
+    // Form Labels
+    'name' => 'Pealkiri',
+    'description' => 'Kirjeldus',
+    'role' => 'Roll',
+    'cover_image' => 'Kaanepilt',
+    'cover_image_description' => 'See pilt peaks olema umbes 440x250 pikslit.',
+
+    // Actions
+    'actions' => 'Tegevused',
+    'view' => 'Vaata',
+    'view_all' => 'Vaata kõiki',
+    'create' => 'Lisa',
+    'update' => 'Uuenda',
+    'edit' => 'Muuda',
+    'sort' => 'Sorteeri',
+    'move' => 'Liiguta',
+    'copy' => 'Kopeeri',
+    'reply' => 'Vasta',
+    'delete' => 'Kustuta',
+    'delete_confirm' => 'Kinnita kustutamine',
+    'search' => 'Otsi',
+    'search_clear' => 'Tühjenda otsing',
+    'reset' => 'Taasta',
+    'remove' => 'Eemalda',
+    'add' => 'Lisa',
+    'configure' => 'Seadista',
+    'fullscreen' => 'Täisekraan',
+    'favourite' => 'Lemmik',
+    'unfavourite' => 'Eemalda lemmik',
+    'next' => 'Järgmine',
+    'previous' => 'Eelmine',
+    'filter_active' => 'Aktiivne filter:',
+    'filter_clear' => 'Tühjenda filter',
+
+    // Sort Options
+    'sort_options' => 'Sorteerimise valikud',
+    'sort_direction_toggle' => 'Sorteerimise suund',
+    'sort_ascending' => 'Sorteeri kasvavalt',
+    'sort_descending' => 'Sorteeri kahanevalt',
+    'sort_name' => 'Pealkiri',
+    'sort_default' => 'Vaikimisi',
+    'sort_created_at' => 'Loomise aeg',
+    'sort_updated_at' => 'Muutmise aeg',
+
+    // Misc
+    'deleted_user' => 'Kustutatud kasutaja',
+    'no_activity' => 'Pole tegevusi, mida näidata',
+    'no_items' => 'Ühtegi elementi pole',
+    'back_to_top' => 'Tagasi üles',
+    'skip_to_main_content' => 'Otse põhisisu juurde',
+    'toggle_details' => 'Näita detaile',
+    'toggle_thumbnails' => 'Näita eelvaateid',
+    'details' => 'Detailid',
+    'grid_view' => 'Tabelivaade',
+    'list_view' => 'Loendivaade',
+    'default' => 'Vaikimisi',
+    'breadcrumb' => 'Jäljerida',
+    'status' => 'Staatus',
+    'status_active' => 'Aktiivne',
+    'status_inactive' => 'Mitteaktiivne',
+    'never' => 'Mitte kunagi',
+    'none' => 'None',
+
+    // Header
+    'header_menu_expand' => 'Laienda päisemenüü',
+    'profile_menu' => 'Profiilimenüü',
+    'view_profile' => 'Vaata profiili',
+    'edit_profile' => 'Muuda profiili',
+    'dark_mode' => 'Tume režiim',
+    'light_mode' => 'Hele režiim',
+
+    // Layout tabs
+    'tab_info' => 'Info',
+    'tab_info_label' => 'Sakk: Näita sekundaarset infot',
+    'tab_content' => 'Sisu',
+    'tab_content_label' => 'Sakk: Näita primaarset sisu',
+
+    // Email Content
+    'email_action_help' => 'Kui sul on probleeme ":actionText" nupu vajutamisega, kopeeri allolev URL oma veebilehitsejasse:',
+    'email_rights' => 'Kõik õigused kaitstud',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privaatsus',
+    'terms_of_service' => 'Kasutustingimused',
+];
diff --git a/resources/lang/et/components.php b/resources/lang/et/components.php
new file mode 100644 (file)
index 0000000..aeb9c3f
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Pildifaili valik',
+    'image_all' => 'Kõik',
+    'image_all_title' => 'Vaata kõiki pildifaile',
+    'image_book_title' => 'Vaata sellesse raamatusse laaditud pildifaile',
+    'image_page_title' => 'Vaata sellele lehele laaditud pildifaile',
+    'image_search_hint' => 'Otsi pildifaili nime järgi',
+    'image_uploaded' => 'Üles laaditud :uploadedDate',
+    'image_load_more' => 'Lae rohkem',
+    'image_image_name' => 'Pildifaili nimi',
+    'image_delete_used' => 'Seda pildifaili kasutavad järgmised lehed.',
+    'image_delete_confirm_text' => 'Kas oled kindel, et soovid selle pildifaili kustutada?',
+    'image_select_image' => 'Vali pildifail',
+    'image_dropzone' => 'Üleslaadimiseks lohista pildid või klõpsa siin',
+    'images_deleted' => 'Pildifailid kustutatud',
+    'image_preview' => 'Pildi eelvaade',
+    'image_upload_success' => 'Pildifail üles laaditud',
+    'image_update_success' => 'Pildifaili andmed muudetud',
+    'image_delete_success' => 'Pildifail kustutatud',
+    'image_upload_remove' => 'Eemalda',
+
+    // Code Editor
+    'code_editor' => 'Muuda koodi',
+    'code_language' => 'Koodi keel',
+    'code_content' => 'Koodi sisu',
+    'code_session_history' => 'Sessiooni ajalugu',
+    'code_save' => 'Salvesta kood',
+];
diff --git a/resources/lang/et/entities.php b/resources/lang/et/entities.php
new file mode 100644 (file)
index 0000000..efcedbd
--- /dev/null
@@ -0,0 +1,347 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Hiljuti lisatud',
+    'recently_created_pages' => 'Hiljuti lisatud lehed',
+    'recently_updated_pages' => 'Hiljuti muudetud lehed',
+    'recently_created_chapters' => 'Hiljuti lisatud peatükid',
+    'recently_created_books' => 'Hiljuti lisatud raamatud',
+    'recently_created_shelves' => 'Hiljuti lisatud riiulid',
+    'recently_update' => 'Hiljuti muudetud',
+    'recently_viewed' => 'Viimati vaadatud',
+    'recent_activity' => 'Hiljutised tegevused',
+    'create_now' => 'Lisa uus',
+    'revisions' => 'Redaktsioonid',
+    'meta_revision' => 'Redaktsioon #:revisionCount',
+    'meta_created' => 'Lisatud :timeLength',
+    'meta_created_name' => 'Lisatud :timeLength kasutaja :user poolt',
+    'meta_updated' => 'Muudetud :timeLength',
+    'meta_updated_name' => 'Muudetud :timeLength kasutaja :user poolt',
+    'meta_owned_name' => 'Kuulub kasutajale :user',
+    'entity_select' => 'Objekti valik',
+    'images' => 'Pildid',
+    'my_recent_drafts' => 'Minu hiljutised mustandid',
+    'my_recently_viewed' => 'Minu viimati vaadatud',
+    'my_most_viewed_favourites' => 'Minu enim vaadatud lemmikud',
+    'my_favourites' => 'Minu lemmikud',
+    'no_pages_viewed' => 'Sa pole veel ühtegi lehte vaadanud',
+    'no_pages_recently_created' => 'Hiljuti pole ühtegi lehte lisatud',
+    'no_pages_recently_updated' => 'Hiljuti pole ühtegi lehte muudetud',
+    'export' => 'Ekspordi',
+    'export_html' => 'HTML-fail',
+    'export_pdf' => 'PDF fail',
+    'export_text' => 'Tekstifail',
+    'export_md' => 'Markdown fail',
+
+    // Permissions and restrictions
+    'permissions' => 'Õigused',
+    'permissions_intro' => 'Kui kohandatud õigused on lubatud, rakendatakse neid eelisjärjekorras, enne rolli õiguseid.',
+    'permissions_enable' => 'Luba kohandatud õigused',
+    'permissions_save' => 'Salvesta õigused',
+    'permissions_owner' => 'Omanik',
+
+    // Search
+    'search_results' => 'Otsingutulemused',
+    'search_total_results_found' => 'leitud :count vaste|leitud :count vastet',
+    'search_clear' => 'Tühjenda otsing',
+    'search_no_pages' => 'Otsing ei leidnud ühtegi lehte',
+    'search_for_term' => 'Otsi terminit :term',
+    'search_more' => 'Rohkem tulemusi',
+    'search_advanced' => 'Täpsem otsing',
+    'search_terms' => 'Otsinguterminid',
+    'search_content_type' => 'Sisu tüüp',
+    'search_exact_matches' => 'Täpsed vasted',
+    'search_tags' => 'Sildi otsing',
+    'search_options' => 'Valikud',
+    'search_viewed_by_me' => 'Minu vaadatud',
+    'search_not_viewed_by_me' => 'Minu vaatamata',
+    'search_permissions_set' => 'Õigused seatud',
+    'search_created_by_me' => 'Minu lisatud',
+    'search_updated_by_me' => 'Minu muudetud',
+    'search_owned_by_me' => 'Minu omad',
+    'search_date_options' => 'Kuupäeva valikud',
+    'search_updated_before' => 'Muudetud enne kui',
+    'search_updated_after' => 'Muudetud hiljem kui',
+    'search_created_before' => 'Lisatud enne kui',
+    'search_created_after' => 'Lisatud hiljem kui',
+    'search_set_date' => 'Vali kuupäev',
+    'search_update' => 'Värskenda otsingutulemusi',
+
+    // Shelves
+    'shelf' => 'Riiul',
+    'shelves' => 'Riiulid',
+    'x_shelves' => ':count riiul|:count riiulit',
+    'shelves_long' => 'Raamaturiiulid',
+    'shelves_empty' => 'Ühtegi riiulit pole lisatud',
+    'shelves_create' => 'Lisa uus riiul',
+    'shelves_popular' => 'Populaarsed riiulid',
+    'shelves_new' => 'Uued riiulid',
+    'shelves_new_action' => 'Uus riiul',
+    'shelves_popular_empty' => 'Siia tulevad kõige populaarsemad riiulid.',
+    'shelves_new_empty' => 'Siia tulevad hiljuti lisatud riiulid.',
+    'shelves_save' => 'Salvesta riiul',
+    'shelves_books' => 'Raamatud sellel riiulil',
+    'shelves_add_books' => 'Lisa sellele riiulile raamatuid',
+    'shelves_drag_books' => 'Lohista raamatuid siia, et neid sellele riiulile lisada',
+    'shelves_empty_contents' => 'Sellel riiulil ei ole ühtegi raamatut',
+    'shelves_edit_and_assign' => 'Muuda riiulit, et siia raamatuid lisada',
+    'shelves_edit_named' => 'Muuda riiulit :name',
+    'shelves_edit' => 'Muuda riiulit',
+    'shelves_delete' => 'Kustuta riiul',
+    'shelves_delete_named' => 'Kustuta riiul :name',
+    'shelves_delete_explain' => "See kustutab riiuli nimega ':name'. Raamatuid, mis on sellel riiulil, ei kustutata.",
+    'shelves_delete_confirmation' => 'Kas oled kindel, et soovid selle raamaturiiuli kustutada?',
+    'shelves_permissions' => 'Riiuli õigused',
+    'shelves_permissions_updated' => 'Riiuli õigused muudetud',
+    'shelves_permissions_active' => 'Riiuli õigused on aktiivsed',
+    'shelves_permissions_cascade_warning' => 'Raamaturiiuli õigused ei rakendu automaatselt sellel olevatele raamatutele, kuna raamat võib olla korraga mitmel riiulil. Alloleva valiku abil saab aga riiuli õigused kopeerida raamatutele.',
+    'shelves_copy_permissions_to_books' => 'Kopeeri õigused raamatutele',
+    'shelves_copy_permissions' => 'Kopeeri õigused',
+    'shelves_copy_permissions_explain' => 'See rakendab raamaturiiuli praegused õigused kõigile sellel olevatele raamatutele. Enne jätkamist veendu, et raamaturiiuli õiguste muudatused oleks salvestatud.',
+    'shelves_copy_permission_success' => 'Raamaturiiuli õigused kopeeritud :count raamatule',
+
+    // Books
+    'book' => 'Raamat',
+    'books' => 'Raamatud',
+    'x_books' => ':count raamat|:count raamatut',
+    'books_empty' => 'Ühtegi raamatut pole lisatud',
+    'books_popular' => 'Populaarsed raamatud',
+    'books_recent' => 'Hiljutised raamatud',
+    'books_new' => 'Uued raamatud',
+    'books_new_action' => 'Uus raamat',
+    'books_popular_empty' => 'Siia tulevad kõige populaarsemad raamatud.',
+    'books_new_empty' => 'Siia tulevad hiljuti lisatud raamatud.',
+    'books_create' => 'Lisa uus raamat',
+    'books_delete' => 'Kustuta raamat',
+    'books_delete_named' => 'Kustuta raamat :bookName',
+    'books_delete_explain' => 'See kustutab raamatu nimega \':bookName\'. Kõik lehed ja peatükid kustutatakse samuti.',
+    'books_delete_confirmation' => 'Kas oled kindel, et soovid selle raamatu kustutada?',
+    'books_edit' => 'Muuda raamatut',
+    'books_edit_named' => 'Muuda raamatut :bookName',
+    'books_form_book_name' => 'Raamatu pealkiri',
+    'books_save' => 'Salvesta raamat',
+    'books_permissions' => 'Raamatu õigused',
+    'books_permissions_updated' => 'Raamatu õigused muudetud',
+    'books_empty_contents' => 'Ühtegi lehte ega peatükki pole lisatud.',
+    'books_empty_create_page' => 'Lisa uus leht',
+    'books_empty_sort_current_book' => 'Sorteeri raamat',
+    'books_empty_add_chapter' => 'Lisa uus peatükk',
+    'books_permissions_active' => 'Raamatu õigused on aktiivsed',
+    'books_search_this' => 'Otsi sellest raamatust',
+    'books_navigation' => 'Raamatu sisukord',
+    'books_sort' => 'Sorteeri raamatu sisu',
+    'books_sort_named' => 'Sorteeri raamat :bookName',
+    'books_sort_name' => 'Sorteeri nime järgi',
+    'books_sort_created' => 'Sorteeri loomisaja järgi',
+    'books_sort_updated' => 'Sorteeri muutmisaja järgi',
+    'books_sort_chapters_first' => 'Peatükid eespool',
+    'books_sort_chapters_last' => 'Peatükid tagapool',
+    'books_sort_show_other' => 'Näita teisi raamatuid',
+    'books_sort_save' => 'Salvesta uus järjekord',
+    'books_copy' => 'Kopeeri raamat',
+    'books_copy_success' => 'Raamat on kopeeritud',
+
+    // Chapters
+    'chapter' => 'Peatükk',
+    'chapters' => 'Peatükid',
+    'x_chapters' => ':count peatükk|:count peatükki',
+    'chapters_popular' => 'Populaarsed peatükid',
+    'chapters_new' => 'Uus peatükk',
+    'chapters_create' => 'Lisa uus peatükk',
+    'chapters_delete' => 'Kustuta peatükk',
+    'chapters_delete_named' => 'Kustuta peatükk :chapterName',
+    'chapters_delete_explain' => 'See kustutab peatüki nimega \':chapterName\'. Kõik lehed selles peatükis kustutatakse samuti.',
+    'chapters_delete_confirm' => 'Kas oled kindel, et soovid selle peatüki kustutada?',
+    'chapters_edit' => 'Muuda peatükki',
+    'chapters_edit_named' => 'Muuda peatükki :chapterName',
+    'chapters_save' => 'Salvesta peatükk',
+    'chapters_move' => 'Liiguta peatükk',
+    'chapters_move_named' => 'Liiguta peatükk :chapterName',
+    'chapter_move_success' => 'Peatükk liigutatud raamatusse :bookName',
+    'chapters_copy' => 'Kopeeri peatükk',
+    'chapters_copy_success' => 'Peatükk on kopeeritud',
+    'chapters_permissions' => 'Peatüki õigused',
+    'chapters_empty' => 'Selles peatükis ei ole lehti.',
+    'chapters_permissions_active' => 'Peatüki õigused on aktiivsed',
+    'chapters_permissions_success' => 'Peatüki õigused muudetud',
+    'chapters_search_this' => 'Otsi sellest peatükist',
+
+    // Pages
+    'page' => 'Leht',
+    'pages' => 'Lehed',
+    'x_pages' => ':count leht|:count lehte',
+    'pages_popular' => 'Populaarsed lehed',
+    'pages_new' => 'Uus leht',
+    'pages_attachments' => 'Manused',
+    'pages_navigation' => 'Lehe sisukord',
+    'pages_delete' => 'Kustuta leht',
+    'pages_delete_named' => 'Kustuta leht :pageName',
+    'pages_delete_draft_named' => 'Kustuta mustand :pageName',
+    'pages_delete_draft' => 'Kustuta mustand',
+    'pages_delete_success' => 'Leht kustutatud',
+    'pages_delete_draft_success' => 'Mustand kustutatud',
+    'pages_delete_confirm' => 'Kas oled kindel, et soovid selle lehe kustutada?',
+    'pages_delete_draft_confirm' => 'Kas oled kindel, et soovid selle mustandi kustutada?',
+    'pages_editing_named' => 'Lehe :pageName muutmine',
+    'pages_edit_draft_options' => 'Mustandi valikud',
+    'pages_edit_save_draft' => 'Salvesta mustand',
+    'pages_edit_draft' => 'Muuda mustandit',
+    'pages_editing_draft' => 'Mustandi muutmine',
+    'pages_editing_page' => 'Lehe muutmine',
+    'pages_edit_draft_save_at' => 'Mustand salvestatud ',
+    'pages_edit_delete_draft' => 'Kustuta mustand',
+    'pages_edit_discard_draft' => 'Loobu mustandist',
+    'pages_edit_set_changelog' => 'Muudatuste logi',
+    'pages_edit_enter_changelog_desc' => 'Sisesta tehtud muudatuste lühikirjeldus',
+    'pages_edit_enter_changelog' => 'Salvesta muudatuste logi',
+    'pages_save' => 'Salvesta leht',
+    'pages_title' => 'Lehe pealkiri',
+    'pages_name' => 'Lehe nimetus',
+    'pages_md_editor' => 'Redaktor',
+    'pages_md_preview' => 'Eelvaade',
+    'pages_md_insert_image' => 'Lisa pilt',
+    'pages_md_insert_link' => 'Lisa viide',
+    'pages_md_insert_drawing' => 'Lisa joonis',
+    'pages_not_in_chapter' => 'Leht ei kuulu peatüki alla',
+    'pages_move' => 'Liiguta leht',
+    'pages_move_success' => 'Leht liigutatud ":parentName" alla',
+    'pages_copy' => 'Kopeeri leht',
+    'pages_copy_desination' => 'Kopeerimise sihtpunkt',
+    'pages_copy_success' => 'Leht on kopeeritud',
+    'pages_permissions' => 'Lehe õigused',
+    'pages_permissions_success' => 'Lehe õigused muudetud',
+    'pages_revision' => 'Redaktsioon',
+    'pages_revisions' => 'Lehe redaktsioonid',
+    'pages_revisions_named' => 'Lehe :pageName redaktsioonid',
+    'pages_revision_named' => 'Lehe :pageName redaktsioon',
+    'pages_revision_restored_from' => 'Taastatud redaktsioonist #:id; :summary',
+    'pages_revisions_created_by' => 'Autor',
+    'pages_revisions_date' => 'Redaktsiooni aeg',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Redaktsioon #:id',
+    'pages_revisions_numbered_changes' => 'Redaktsiooni #:id muudatused',
+    'pages_revisions_changelog' => 'Muudatuste ajalugu',
+    'pages_revisions_changes' => 'Muudatused',
+    'pages_revisions_current' => 'Praegune versioon',
+    'pages_revisions_preview' => 'Eelvaade',
+    'pages_revisions_restore' => 'Taasta',
+    'pages_revisions_none' => 'Sellel lehel ei ole redaktsioone',
+    'pages_copy_link' => 'Kopeeri link',
+    'pages_edit_content_link' => 'Muuda sisu',
+    'pages_permissions_active' => 'Lehe õigused on aktiivsed',
+    'pages_initial_revision' => 'Esimene redaktsioon',
+    'pages_initial_name' => 'Uus leht',
+    'pages_editing_draft_notification' => 'Sa muudad mustandit, mis salvestati viimati :timeDiff.',
+    'pages_draft_edited_notification' => 'Seda lehte on sellest ajast saadid uuendatud. Soovitame mustandist loobuda.',
+    'pages_draft_page_changed_since_creation' => 'Seda lehte on pärast mustandi loomist muudetud. Soovitame mustandi ära visata või olla hoolikas, et mitte lehe muudatusi üle kirjutada.',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count kasutajat on selle lehe muutmist alustanud',
+        'start_b' => ':userName alustas selle lehe muutmist',
+        'time_a' => 'lehe viimasest muutmisest alates',
+        'time_b' => 'viimase :minCount minuti jooksul',
+        'message' => ':start :time. Ärge teineteise muudatusi üle kirjutage!',
+    ],
+    'pages_draft_discarded' => 'Mustand ära visatud, redaktorisse laeti lehe värske sisu',
+    'pages_specific' => 'Spetsiifiline leht',
+    'pages_is_template' => 'Lehe mall',
+
+    // Editor Sidebar
+    'page_tags' => 'Lehe sildid',
+    'chapter_tags' => 'Peatüki sildid',
+    'book_tags' => 'Raamatu sildid',
+    'shelf_tags' => 'Riiuli sildid',
+    'tag' => 'Silt',
+    'tags' =>  'Sildid',
+    'tag_name' =>  'Sildi nimi',
+    'tag_value' => 'Sildi väärtus (valikuline)',
+    'tags_explain' => "Lisa silte, et sisu paremini organiseerida.\nVeel täpsemaks organiseerimiseks saad siltidele väärtuseid määrata.",
+    'tags_add' => 'Lisa veel üks silt',
+    'tags_remove' => 'Eemalda see silt',
+    'tags_usages' => 'Siltide kasutus',
+    'tags_assigned_pages' => 'Lisatud lehtedele',
+    'tags_assigned_chapters' => 'Lisatud peatükkidele',
+    'tags_assigned_books' => 'Lisatud raamatutele',
+    'tags_assigned_shelves' => 'Lisatud riiulitele',
+    'tags_x_unique_values' => ':count unikaalset',
+    'tags_all_values' => 'Kõik väärtused',
+    'tags_view_tags' => 'Vaata silte',
+    'tags_view_existing_tags' => 'Vaata olemasolevaid silte',
+    'tags_list_empty_hint' => 'Silte saab lisada lehe redaktori külgmenüü kaudu või raamatu, peatüki või riiuli andmeid muutes.',
+    'attachments' => 'Manused',
+    'attachments_explain' => 'Laadi üles faile või lisa linke, mida lehel kuvada. Need on nähtavad külgmenüüs.',
+    'attachments_explain_instant_save' => 'Muudatused salvestatakse koheselt.',
+    'attachments_items' => 'Lisatud objektid',
+    'attachments_upload' => 'Laadi fail üles',
+    'attachments_link' => 'Lisa link',
+    'attachments_set_link' => 'Määra link',
+    'attachments_delete' => 'Kas oled kindel, et soovid selle manuse kustutada?',
+    'attachments_dropzone' => 'Manuse lisamiseks lohista failid või klõpsa siin',
+    'attachments_no_files' => 'Üleslaaditud faile ei ole',
+    'attachments_explain_link' => 'Faili üleslaadimise asemel saad lingi lisada. See võib viidata teisele lehele või failile kuskil pilves.',
+    'attachments_link_name' => 'Lingi nimi',
+    'attachment_link' => 'Manuse link',
+    'attachments_link_url' => 'Link failile',
+    'attachments_link_url_hint' => 'Lehekülje või faili URL',
+    'attach' => 'Lisa',
+    'attachments_insert_link' => 'Lisa manuse link lehele',
+    'attachments_edit_file' => 'Muuda faili',
+    'attachments_edit_file_name' => 'Faili nimi',
+    'attachments_edit_drop_upload' => 'Manuse üle kirjutamiseks lohista failid või klõpsa siin',
+    'attachments_order_updated' => 'Manuste järjekord muudetud',
+    'attachments_updated_success' => 'Manuse andmed muudetud',
+    'attachments_deleted' => 'Manus kustutatud',
+    'attachments_file_uploaded' => 'Fail on üles laaditud',
+    'attachments_file_updated' => 'Fail on muudetud',
+    'attachments_link_attached' => 'Link on lehele lisatud',
+    'templates' => 'Mallid',
+    'templates_set_as_template' => 'Leht on mall',
+    'templates_explain_set_as_template' => 'Sa saad määrata selle lehe malliks, nii et selle sisu saab kasutada uute lehtede lisamisel. Kui teistel kasutajatel on selle lehe vaatamiseks õigus, saavad ka nemad seda mallina kasutada.',
+    'templates_replace_content' => 'Asenda lehe sisu',
+    'templates_append_content' => 'Lisa lehe sisu järele',
+    'templates_prepend_content' => 'Lisa lehe sisu ette',
+
+    // Profile View
+    'profile_user_for_x' => 'Kasutaja olnud :time',
+    'profile_created_content' => 'Lisatud sisu',
+    'profile_not_created_pages' => ':userName ei ole ühtegi lehte lisanud',
+    'profile_not_created_chapters' => ':userName ei ole ühtegi peatükki lisanud',
+    'profile_not_created_books' => ':userName ei ole ühtegi raamatut lisanud',
+    'profile_not_created_shelves' => ':userName ei ole ühtegi riiulit lisanud',
+
+    // Comments
+    'comment' => 'Kommentaar',
+    'comments' => 'Kommentaarid',
+    'comment_add' => 'Lisa kommentaar',
+    'comment_placeholder' => 'Jäta siia kommentaar',
+    'comment_count' => '{0} Kommentaare pole|{1} 1 kommentaar|[2,*] :count kommentaari',
+    'comment_save' => 'Salvesta kommentaar',
+    'comment_saving' => 'Kommentaari salvestamine...',
+    'comment_deleting' => 'Kommentaari kustutamine...',
+    'comment_new' => 'Uus kommentaar',
+    'comment_created' => 'kommenteeris :createDiff',
+    'comment_updated' => 'Muudetud :updateDiff :username poolt',
+    'comment_deleted_success' => 'Kommentaar kustutatud',
+    'comment_created_success' => 'Kommentaar lisatud',
+    'comment_updated_success' => 'Kommentaar muudetud',
+    'comment_delete_confirm' => 'Kas oled kindel, et soovid selle kommentaari kustutada?',
+    'comment_in_reply_to' => 'Vastus kommentaarile :commentId',
+
+    // Revision
+    'revision_delete_confirm' => 'Kas oled kindel, et soovid selle redaktsiooni kustutada?',
+    'revision_restore_confirm' => 'Kas oled kindel, et soovid selle redaktsiooni taastada? Lehe praegune sisu asendatakse.',
+    'revision_delete_success' => 'Redaktsioon kustutatud',
+    'revision_cannot_delete_latest' => 'Kõige viimast redaktsiooni ei saa kustutada.',
+
+    // Copy view
+    'copy_consider' => 'Sisu kopeerimisel pea järgnevat meeles.',
+    'copy_consider_permissions' => 'Kohandatud õiguseid ei kopeerita.',
+    'copy_consider_owner' => 'Sind määratakse kopeeritud sisu omanikuks.',
+    'copy_consider_images' => 'Lehel olevaid pildifaile ei dubleerita. Pildid säilitavad viite lehele, millele nad algselt lisati.',
+    'copy_consider_attachments' => 'Lehe manuseid ei kopeerita.',
+    'copy_consider_access' => 'Asukoha, omaniku või õiguste muudatused võivad teha sisu kättesaadavaks neile, kellel varem sellele ligipääs puudus.',
+];
diff --git a/resources/lang/et/errors.php b/resources/lang/et/errors.php
new file mode 100644 (file)
index 0000000..b61dbb1
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Sul puudub õigus selle lehe vaatamiseks.',
+    'permissionJson' => 'Sul puudub õigus selle tegevuse teostamiseks.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'See e-posti aadress on juba seotud teise kasutajaga.',
+    'email_already_confirmed' => 'E-posti aadress on juba kinnitatud. Proovi sisse logida.',
+    'email_confirmation_invalid' => 'Kinnituslink ei ole kehtiv või on seda juba kasutatud. Proovi uuesti registreeruda.',
+    'email_confirmation_expired' => 'Kinnituslink on aegunud. Sulle saadeti aadressi kinnitamiseks uus e-kiri.',
+    'email_confirmation_awaiting' => 'Selle kasutajakonto e-posti aadress vajab kinnitamist',
+    'ldap_fail_anonymous' => 'LDAP anonüümne ligipääs ebaõnnestus',
+    'ldap_fail_authed' => 'LDAP ligipääs antud nime ja parooliga ebaõnnestus',
+    'ldap_extension_not_installed' => 'PHP LDAP laiendus ei ole paigaldatud',
+    'ldap_cannot_connect' => 'Ühendus LDAP serveriga ebaõnnestus',
+    'saml_already_logged_in' => 'Juba sisse logitud',
+    'saml_user_not_registered' => 'Kasutaja :name ei ole registreeritud ning automaatne registreerimine on keelatud',
+    'saml_no_email_address' => 'Selle kasutaja e-posti aadressi ei õnnestunud välisest autentimissüsteemist leida',
+    'saml_invalid_response_id' => 'Välisest autentimissüsteemist tulnud päringut ei algatatud sellest rakendusest. Seda viga võib põhjustada pärast sisselogimist tagasi liikumine.',
+    'saml_fail_authed' => 'Sisenemine :system kaudu ebaõnnestus, süsteem ei andnud volitust',
+    'oidc_already_logged_in' => 'Juba sisse logitud',
+    'oidc_user_not_registered' => 'Kasutaja :name ei ole registreeritud ning automaatne registreerimine on keelatud',
+    'oidc_no_email_address' => 'Selle kasutaja e-posti aadressi ei õnnestunud välisest autentimissüsteemist leida',
+    'oidc_fail_authed' => 'Sisenemine :system kaudu ebaõnnestus, süsteem ei andnud volitust',
+    'social_no_action_defined' => 'Tegevus on defineerimata',
+    'social_login_bad_response' => ":socialAccount kaudu sisselogimisel tekkis viga: \n:error",
+    'social_account_in_use' => 'See :socialAccount konto on juba kasutusel, proovi :socialAccount kaudu sisse logida.',
+    'social_account_email_in_use' => 'E-posti aadress :email on juba kasutusel. Kui sul on juba kasutajakonto, saad oma :socialAccount konto siduda profiili seadetes.',
+    'social_account_existing' => 'See :socialAccount konto on juba seotud su profiiliga.',
+    'social_account_already_used_existing' => 'See :socialAccount konto on juba seotud teise kasutajaga.',
+    'social_account_not_used' => 'See :socialAccount konto ei ole seotud ühegi kasutajaga. Seosta see oma profiili seadetes. ',
+    'social_account_register_instructions' => 'Kui sul pole veel kasutajakontot, saad selle registreerida :socialAccount kaudu.',
+    'social_driver_not_found' => 'Sotsiaalmeedia kontode draiverit ei leitud',
+    'social_driver_not_configured' => 'Sinu :socialAccount konto seaded ei ole korrektsed.',
+    'invite_token_expired' => 'Link on aegunud. Võid selle asemel proovida oma konto parooli lähtestada.',
+
+    // System
+    'path_not_writable' => 'Faili asukohaga :filePath ei õnnestunud üles laadida. Veendu, et serveril on kirjutusõigused.',
+    'cannot_get_image_from_url' => 'Ei suutnud laadida pilti aadressilt :url',
+    'cannot_create_thumbs' => 'Server ei saa piltide eelvaateid tekitada. Veendu, et PHP GD laiendus on paigaldatud.',
+    'server_upload_limit' => 'Server ei luba nii suurte failide üleslaadimist. Proovi väiksema failiga.',
+    'uploaded'  => 'Server ei luba nii suurte failide üleslaadimist. Proovi väiksema failiga.',
+    'image_upload_error' => 'Pildi üleslaadimisel tekkis viga',
+    'image_upload_type_error' => 'Pildifaili tüüp ei ole korrektne',
+    'file_upload_timeout' => 'Faili üleslaadimine aegus.',
+
+    // Attachments
+    'attachment_not_found' => 'Manust ei leitud',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Mustandi salvestamine ebaõnnestus. Kontrolli oma internetiühendust',
+    'page_custom_home_deletion' => 'Ei saa kustutada lehte, mis on määratud avaleheks',
+
+    // Entities
+    'entity_not_found' => 'Objekti ei leitud',
+    'bookshelf_not_found' => 'Riiulit ei leitud',
+    'book_not_found' => 'Raamatut ei leitud',
+    'page_not_found' => 'Lehte ei leitud',
+    'chapter_not_found' => 'Peatükki ei leitud',
+    'selected_book_not_found' => 'Valitud raamatut ei leitud',
+    'selected_book_chapter_not_found' => 'Valitud raamatut või peatükki ei leitud',
+    'guests_cannot_save_drafts' => 'Külalised ei saa mustandeid salvestada',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Ainsat administraatorit ei saa kustutada',
+    'users_cannot_delete_guest' => 'Külaliskasutajat ei saa kustutada',
+
+    // Roles
+    'role_cannot_be_edited' => 'Seda rolli ei saa muuta',
+    'role_system_cannot_be_deleted' => 'See roll on süsteemne ja seda ei saa kustutada',
+    'role_registration_default_cannot_delete' => 'Seda rolli ei saa kustutada, kuna see on seatud uute kasutajate vaikimisi rolliks',
+    'role_cannot_remove_only_admin' => 'See kasutaja on ainus, kellel on administraatori roll. Enne kustutamist lisa administraatori roll mõnele teisele kasutajale.',
+
+    // Comments
+    'comment_list' => 'Kommentaaride pärimisel tekkis viga.',
+    'cannot_add_comment_to_draft' => 'Mustandile ei saa kommentaare lisada.',
+    'comment_add' => 'Kommentaari lisamisel / muutmisel tekkis viga.',
+    'comment_delete' => 'Kommentaari kustutamisel tekkis viga.',
+    'empty_comment' => 'Tühja kommentaari ei saa lisada.',
+
+    // Error pages
+    '404_page_not_found' => 'Lehekülge ei leitud',
+    'sorry_page_not_found' => 'Vabandust, soovitud lehekülge ei leitud.',
+    'sorry_page_not_found_permission_warning' => 'Kui see lehekülg peaks kindlalt olemas olema, ei pruugi sul olla õigust selle vaatamiseks.',
+    'image_not_found' => 'Pildifaili ei leitud',
+    'image_not_found_subtitle' => 'Vabandust, soovitud pildifaili ei leitud.',
+    'image_not_found_details' => 'Kui sa eeldasid, et see pildifail on olemas, võib see olla kustutatud.',
+    'return_home' => 'Tagasi avalehele',
+    'error_occurred' => 'Tekkis viga',
+    'app_down' => ':appName on hetkel maas',
+    'back_soon' => 'See on varsti tagasi.',
+
+    // API errors
+    'api_no_authorization_found' => 'Päringust ei leitud volitustunnust',
+    'api_bad_authorization_format' => 'Päringust leiti volitustunnus, aga see ei olnud korrektses formaadis',
+    'api_user_token_not_found' => 'Volitustunnusele vastavat API tunnust ei leitud',
+    'api_incorrect_token_secret' => 'API tunnusele lisatud salajane võti ei ole korrektne',
+    'api_user_no_api_permission' => 'Selle API tunnuse omanikul ei ole õigust API päringuid teha',
+    'api_user_token_expired' => 'Volitustunnus on aegunud',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Test e-kirja saatmisel tekkis viga:',
+
+];
diff --git a/resources/lang/et/pagination.php b/resources/lang/et/pagination.php
new file mode 100644 (file)
index 0000000..c32b7f3
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Pagination Language Lines
+ * The following language lines are used by the paginator library to build
+ * the simple pagination links.
+ */
+return [
+
+    'previous' => '&laquo; Eelmine',
+    'next'     => 'Järgmine &raquo;',
+
+];
diff --git a/resources/lang/et/passwords.php b/resources/lang/et/passwords.php
new file mode 100644 (file)
index 0000000..8559d60
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Password Reminder Language Lines
+ * The following language lines are the default lines which match reasons
+ * that are given by the password broker for a password update attempt has failed.
+ */
+return [
+
+    'password' => 'Paroolides peab olema vähemalt kaheksa tähemärki ja nad peavad omavahel ühtima.',
+    'user' => "Sellise e-posti aadressiga kasutajat ei leitud.",
+    'token' => 'Parooli lähtestamise link ei kehti selle e-posti aadressiga.',
+    'sent' => 'Parooli lähtestamise link saadeti e-postiga!',
+    'reset' => 'Parool on lähtestatud!',
+
+];
diff --git a/resources/lang/et/settings.php b/resources/lang/et/settings.php
new file mode 100644 (file)
index 0000000..8089efe
--- /dev/null
@@ -0,0 +1,306 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Seaded',
+    'settings_save' => 'Salvesta seaded',
+    'settings_save_success' => 'Seaded salvestatud',
+
+    // App Settings
+    'app_customization' => 'Kohandamine',
+    'app_features_security' => 'Funktsioonid ja turvalisus',
+    'app_name' => 'Rakenduse nimi',
+    'app_name_desc' => 'Seda nime näidatakse päises ja kõigis süsteemsetes e-kirjades.',
+    'app_name_header' => 'Näita nime päises',
+    'app_public_access' => 'Avalik ligipääs',
+    'app_public_access_desc' => 'Selle sisselülitamine võimaldab külalistel ilma sisselogimata ligipääsu su BookStack\'i sisule.',
+    'app_public_access_desc_guest' => 'Sisselogimata kasutajate ligipääsu saab seadistada "Külaline" kasutaja kaudu.',
+    'app_public_access_toggle' => 'Luba avalik ligipääs',
+    'app_public_viewing' => 'Luba avalik ligipääs?',
+    'app_secure_images' => 'Turvalisem piltide üleslaadimine',
+    'app_secure_images_toggle' => 'Lülita sisse turvalisem piltide üleslaadimine',
+    'app_secure_images_desc' => 'Jõudluse kaalutlustel on kõik pildifailid avalikult kättesaadavad. See valik lisab pildifailide URL-ide ette juhugenereeritud, raskesti arvatava stringi. Ligipääsu piiramiseks veendu, et kataloogide indekseerimine ei oleks lubatud.',
+    'app_editor' => 'Redaktor',
+    'app_editor_desc' => 'Vali, millist redaktorit kasutajad lehtede muutmiseks kasutavad.',
+    'app_custom_html' => 'Kohandatud HTML päise sisu',
+    'app_custom_html_desc' => 'Siia lisatud sisu lisatakse iga lehe <head> sektsiooni lõppu. See võimaldab stiile üle laadida või lisada analüütika koodi.',
+    'app_custom_html_disabled_notice' => 'Kohandatud HTML päise sisu on sellel lehel välja lülitatud, et probleemseid muudatusi saaks tagasi võtta.',
+    'app_logo' => 'Rakenduse logo',
+    'app_logo_desc' => 'See pildifail peaks olema 43 pikslit kõrge. <br>Suuremad pildifailid tehakse väiksemaks.',
+    'app_primary_color' => 'Rakenduse põhivärv',
+    'app_primary_color_desc' => 'Määrab rakenduse primaarse värvi, sh. päise, nuppude ja linkide jaoks.',
+    'app_homepage' => 'Rakenduse avaleht',
+    'app_homepage_desc' => 'Vali leht, mida näidata avalehel vaikimisi vaate asemel. Valitud lehele ei rakendata ligipääsuõiguseid.',
+    'app_homepage_select' => 'Vali leht',
+    'app_footer_links' => 'Lingid jaluses',
+    'app_footer_links_desc' => 'Lisa rakenduse jalusesse linke. Neid näidatakse enamike lehtede jaluses, kaasa arvatud need, mis ei vaja sisselogimist. Võid kasutada märgendit "trans::<key>", et kasutada süsteemseid tõlkeid. Näiteks "trans::common.privacy_policy" tekitab tõlgitud teksti "Privaatsus" ning "trans::common.terms_of_service" tekitab tõlgitud teksti "Kasutustingimused".',
+    'app_footer_links_label' => 'Lingi tekst',
+    'app_footer_links_url' => 'Lingi URL',
+    'app_footer_links_add' => 'Lisa link',
+    'app_disable_comments' => 'Keela kommentaarid',
+    'app_disable_comments_toggle' => 'Keela kommentaarid',
+    'app_disable_comments_desc' => 'Keelab kommentaarid kogu rakenduses. <br>Olemasolevaid kommentaare ei näidata.',
+
+    // Color settings
+    'content_colors' => 'Sisuelementide värvid',
+    'content_colors_desc' => 'Määrab värvid erinevatele sisuelementidele. Loetavuse huvides on soovituslik valida värvid, mille heledus on sarnane vaikimisi värvidele.',
+    'bookshelf_color' => 'Riiuli värv',
+    'book_color' => 'Raamatu värv',
+    'chapter_color' => 'Peatüki värv',
+    'page_color' => 'Lehe värv',
+    'page_draft_color' => 'Mustandi värv',
+
+    // Registration Settings
+    'reg_settings' => 'Registreerumine',
+    'reg_enable' => 'Luba registreerumine',
+    'reg_enable_toggle' => 'Luba registreerumine',
+    'reg_enable_desc' => 'Kui registreerumine on lubatud, saavad kasutajad ise endale rakenduse konto tekitada, ning neile antakse vaikimisi roll.',
+    'reg_default_role' => 'Vaikimisi roll uutele kasutajatele',
+    'reg_enable_external_warning' => 'Ülalolevat valikut ignoreeritakse, kui väline LDAP või SAML autentimine on aktiivne. Kui autentimine välise süsteemi vastu on edukas, genereeritakse puuduvad kasutajadkontod automaatselt.',
+    'reg_email_confirmation' => 'E-posti aadressi kinnitus',
+    'reg_email_confirmation_toggle' => 'Nõua e-posti aadressi kinnitamist',
+    'reg_confirm_email_desc' => 'Kui domeeni piirang on kasutusel, siis on e-posti aadressi kinnitamine nõutud ja seda seadet ignoreeritakse.',
+    'reg_confirm_restrict_domain' => 'Domeeni piirang',
+    'reg_confirm_restrict_domain_desc' => 'Sisesta komaga eraldatud nimekiri e-posti domeenidest, millega soovid registreerumist piirata. Kasutajale saadetakse aadressi kinnitamiseks e-kiri, enne kui neil lubatakse rakendust kasutada.<br>Pane tähele, et kasutajad saavad pärast edukat registreerumist oma e-posti aadressi muuta.',
+    'reg_confirm_restrict_domain_placeholder' => 'Piirangut ei ole',
+
+    // Maintenance settings
+    'maint' => 'Hooldus',
+    'maint_image_cleanup' => 'Pildifailide koristus',
+    'maint_image_cleanup_desc' => 'Kontrollib lehtede ja redaktsioonide sisu, et leida pilte ja jooniseid, mis enam kasutusel ei ole. Enne selle käivitamist tee andmebaasist ja pildifailidest täielik varukoopia.',
+    'maint_delete_images_only_in_revisions' => 'Kustuta ka pildifailid, mis on kasutusel ainult vanades redaktsioonides',
+    'maint_image_cleanup_run' => 'Käivita koristus',
+    'maint_image_cleanup_warning' => 'Leiti :count potentsiaalselt kasutamata pildifaili. Kas oled kindel, et soovid need kustutada?',
+    'maint_image_cleanup_success' => 'Leiti ja kustutati :count potentsiaalselt kasutamata pildifaili!',
+    'maint_image_cleanup_nothing_found' => 'Kasutamata pildifaile ei leitud, pole midagi kustutada!',
+    'maint_send_test_email' => 'Saada testimiseks e-kiri',
+    'maint_send_test_email_desc' => 'See saadab testimiseks e-kirja su profiilis märgitud aadressile.',
+    'maint_send_test_email_run' => 'Saada test e-kiri',
+    'maint_send_test_email_success' => 'E-kiri saadetud aadressile :address',
+    'maint_send_test_email_mail_subject' => 'Test e-kiri',
+    'maint_send_test_email_mail_greeting' => 'E-kirjade saatmine tundub toimivat!',
+    'maint_send_test_email_mail_text' => 'Hea töö! Kui sa selle e-kirja kätte said, on su e-posti seaded õigesti määratud.',
+    'maint_recycle_bin_desc' => 'Kustutatud riiulid, raamatud, peatükid ja lehed saadetakse prügikasti, et neid saaks taastada või lõplikult kustutada. Vanemad objektid võidakse teatud aja järel automaatselt prügikastist kustutada.',
+    'maint_recycle_bin_open' => 'Ava prügikast',
+
+    // Recycle Bin
+    'recycle_bin' => 'Prügikast',
+    'recycle_bin_desc' => 'Siin saad taastada kustutatud objekte, või neid süsteemist lõplikult eemaldada. Nimekiri on filtreerimata, mitte nagu mujal tegevusloendites, kus rakenduvad õigused.',
+    'recycle_bin_deleted_item' => 'Kustutatud objekt',
+    'recycle_bin_deleted_parent' => 'Ülemobjekt',
+    'recycle_bin_deleted_by' => 'Kustutaja',
+    'recycle_bin_deleted_at' => 'Kustutamise aeg',
+    'recycle_bin_permanently_delete' => 'Kustuta lõplikult',
+    'recycle_bin_restore' => 'Taasta',
+    'recycle_bin_contents_empty' => 'Prügikast on hetkel tühi',
+    'recycle_bin_empty' => 'Tühjenda prügikast',
+    'recycle_bin_empty_confirm' => 'See kustutab lõplikult kõik objektid prügikastis, kaasa arvatud nende sisu. Kas oled kindel, et soovid prügikasti tühjendada?',
+    'recycle_bin_destroy_confirm' => 'See kustutab lõplikult valitud objekti koos loetletud alamobjektidega, ja seda sisu ei ole enam võimalik taastada. Kas oled kindel, et soovid selle objekti kustutada?',
+    'recycle_bin_destroy_list' => 'Kustutatavad objektid',
+    'recycle_bin_restore_list' => 'Taastatavad objektid',
+    'recycle_bin_restore_confirm' => 'See taastab valitud objekti koos kõigi alamobjektidega nende algsesse asukohta. Kui see asukoht on ka vahepeal kustutatud ja on nüüd prügikastis, tuleb ka see taastada.',
+    'recycle_bin_restore_deleted_parent' => 'Selle objekti ülemobjekt on ka kustutatud. Taastada ei saa enne, kui ülemobjekt on taastatud.',
+    'recycle_bin_restore_parent' => 'Taasta ülemobjekt',
+    'recycle_bin_destroy_notification' => 'Prügikastist kustutati :count objekti.',
+    'recycle_bin_restore_notification' => 'Prügikastist taastati :count objekti.',
+
+    // Audit Log
+    'audit' => 'Auditilogi',
+    'audit_desc' => 'Auditilogi kuvab nimekirja tegevustest, mida süsteem jälgib. See nimekiri on filtreerimata, erinevalt muudest loenditest süsteemis, kus rakenduvad õigused.',
+    'audit_event_filter' => 'Sündmuse filter',
+    'audit_event_filter_no_filter' => 'Ilma filtrita',
+    'audit_deleted_item' => 'Kustutatud objekt',
+    'audit_deleted_item_name' => 'Nimi: :name',
+    'audit_table_user' => 'Kasutaja',
+    'audit_table_event' => 'Sündmus',
+    'audit_table_related' => 'Seotud objekt või detail',
+    'audit_table_ip' => 'IP-aadress',
+    'audit_table_date' => 'Tegevuse aeg',
+    'audit_date_from' => 'Kuupäev alates',
+    'audit_date_to' => 'Kuupäev kuni',
+
+    // Role Settings
+    'roles' => 'Rollid',
+    'role_user_roles' => 'Kasutaja rollid',
+    'role_create' => 'Lisa uus roll',
+    'role_create_success' => 'Roll on lisatud',
+    'role_delete' => 'Kustuta roll',
+    'role_delete_confirm' => 'See kustutab rolli nimega \':roleName\'.',
+    'role_delete_users_assigned' => 'Selle rolliga on seotud :userCount kasutajat. Kui soovid neile selle asemel uue rolli määrata, siis vali see allpool.',
+    'role_delete_no_migration' => "Ära määra uut rolli",
+    'role_delete_sure' => 'Kas oled kindel, et soovid selle rolli kustutada?',
+    'role_delete_success' => 'Roll on kustutatud',
+    'role_edit' => 'Muuda rolli',
+    'role_details' => 'Rolli detailid',
+    'role_name' => 'Rolli nimi',
+    'role_desc' => 'Rolli lühike kirjeldus',
+    'role_mfa_enforced' => 'Vajab mitmeastmelist autentimist',
+    'role_external_auth_id' => 'Välise autentimise ID-d',
+    'role_system' => 'Süsteemsed õigused',
+    'role_manage_users' => 'Kasutajate haldamine',
+    'role_manage_roles' => 'Rollide ja õiguste haldamine',
+    'role_manage_entity_permissions' => 'Kõigi raamatute, peatükkide ja lehtede õiguste haldamine',
+    'role_manage_own_entity_permissions' => 'Oma raamatute, peatükkide ja lehtede õiguste haldamine',
+    'role_manage_page_templates' => 'Mallide haldamine',
+    'role_access_api' => 'Süsteemi API ligipääs',
+    'role_manage_settings' => 'Rakenduse seadete haldamine',
+    'role_export_content' => 'Sisu eksport',
+    '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_all' => 'Kõik',
+    'role_own' => 'Enda omad',
+    'role_controlled_by_asset' => 'Õigused määratud seotud objekti kaudu',
+    'role_save' => 'Salvesta roll',
+    'role_update_success' => 'Roll on muudetud',
+    'role_users' => 'Selle rolliga kasutajad',
+    'role_users_none' => 'Seda rolli ei ole hetkel ühelgi kasutajal',
+
+    // Users
+    'users' => 'Kasutajad',
+    'user_profile' => 'Kasutajaprofiil',
+    'users_add_new' => 'Lisa uus kasutaja',
+    'users_search' => 'Otsi kasutajaid',
+    'users_latest_activity' => 'Viimane tegevus',
+    'users_details' => 'Kasutaja andmed',
+    'users_details_desc' => 'Määra kasutajale nimi ja e-posti aadress. E-posti aadressi kasutatakse rakendusse sisse logimiseks.',
+    'users_details_desc_no_email' => 'Määra kasutajale nimi, mille järgi teised ta ära tunnevad.',
+    'users_role' => 'Kasutaja rollid',
+    'users_role_desc' => 'Vali, millised rollid sellel kasutajal on. Kui talle on valitud mitu rolli, siis nende õigused kombineeritakse ja kasutaja saab kõigi rollide õigused.',
+    'users_password' => 'Kasutaja parool',
+    'users_password_desc' => 'Määra parool, millega rakendusse sisse logida. See peab olema vähemalt 8 tähemärki.',
+    'users_send_invite_text' => 'Sa võid kasutajale saata e-postiga kutse, mis võimaldab neil ise parooli seada. Vastasel juhul määra parool ise.',
+    'users_send_invite_option' => 'Saada e-postiga kutse',
+    'users_external_auth_id' => 'Välise autentimise ID',
+    'users_external_auth_id_desc' => 'Selle ID abil identifitseeritakse kasutajat välise autentimissüsteemiga suhtlemisel.',
+    'users_password_warning' => 'Täida allolevad väljad ainult siis, kui soovid oma parooli muuta.',
+    'users_system_public' => 'See kasutaja tähistab kõiki külalisi, kes su rakendust vaatavad. Selle kontoga ei saa sisse logida, see määratakse automaatselt.',
+    'users_delete' => 'Kustuta kasutaja',
+    'users_delete_named' => 'Kustuta kasutaja :userName',
+    'users_delete_warning' => 'See kustutab kasutaja nimega \':userName\' süsteemist täielikult.',
+    'users_delete_confirm' => 'Kas oled kindel, et soovid selle kasutaja kustutada?',
+    'users_migrate_ownership' => 'Teisalda omandus',
+    'users_migrate_ownership_desc' => 'Vali siin kasutaja, kui soovid talle üle viia kõik selle kasutaja objektid.',
+    'users_none_selected' => 'Kasutaja valimata',
+    'users_delete_success' => 'Kasutaja on kustutatud',
+    'users_edit' => 'Muuda kasutajat',
+    'users_edit_profile' => 'Muuda profiili',
+    'users_edit_success' => 'Kasutaja on muudetud',
+    'users_avatar' => 'Kasutaja profiilipilt',
+    'users_avatar_desc' => 'Vali sellele kasutajale profiilipilt. See peaks olema umbes 256x256 pikslit.',
+    'users_preferred_language' => 'Eelistatud keel',
+    'users_preferred_language_desc' => 'See valik muudab rakenduse kasutajaliidese keelt. Kasutajate loodud sisu see ei mõjuta.',
+    'users_social_accounts' => 'Sotsiaalmeedia kontod',
+    'users_social_accounts_info' => 'Siin saad seostada teised kontod, millega kiiremini ja lihtsamini sisse logida. Siit konto eemaldamine ei tühista varem lubatud ligipääsu. Ligipääsu saad tühistada ühendatud konto profiili seadetest.',
+    'users_social_connect' => 'Lisa konto',
+    'users_social_disconnect' => 'Eemalda konto',
+    'users_social_connected' => ':socialAccount konto lisati su profiilile.',
+    'users_social_disconnected' => ':socialAccount konto eemaldati su profiililt.',
+    'users_api_tokens' => 'API tunnused',
+    'users_api_tokens_none' => 'Sellel kasutajal pole API tunnuseid',
+    'users_api_tokens_create' => 'Lisa tunnus',
+    'users_api_tokens_expires' => 'Aegub',
+    'users_api_tokens_docs' => 'API dokumentatsioon',
+    'users_mfa' => 'Mitmeastmeline autentimine',
+    'users_mfa_desc' => 'Seadista mitmeastmeline autentimine, et oma kasutajakonto turvalisust tõsta.',
+    'users_mfa_x_methods' => ':count meetod seadistatud|:count meetodit seadistatud',
+    'users_mfa_configure' => 'Seadista meetodid',
+
+    // API Tokens
+    'user_api_token_create' => 'Lisa API tunnus',
+    'user_api_token_name' => 'Nimi',
+    'user_api_token_name_desc' => 'Anna oma tunnusele inimloetav nimi, et selle eesmärk paremini meeles püsiks.',
+    'user_api_token_expiry' => 'Kehtiv kuni',
+    'user_api_token_expiry_desc' => 'Määra kuupäev, millal see tunnus aegub. Pärast seda kuupäeva ei saa selle tunnusega enam päringuid teha. Välja tühjaks jätmine määrab aegumiskuupäeva 100 aastat tulevikku.',
+    'user_api_token_create_secret_message' => 'Kohe pärast selle tunnuse loomist genereeritakse ja kuvatakse tunnuse ID ja salajane võti. Võtit kuvatakse ainult ühe korra, seega kopeeri selle väärtus enne jätkamist turvalisse kohta.',
+    'user_api_token_create_success' => 'API tunnus on lisatud',
+    'user_api_token_update_success' => 'API tunnus on muudetud',
+    'user_api_token' => 'API tunnus',
+    'user_api_token_id' => 'Tunnuse ID',
+    'user_api_token_id_desc' => 'See on API tunnuse süsteemne mittemuudetav identifikaator, mis tuleb API päringutele kaasa panna.',
+    'user_api_token_secret' => 'Tunnuse võti',
+    'user_api_token_secret_desc' => 'See on API tunnuse salajane võti, mis tuleb API päringutele kaasa panna. Seda kuvatakse ainult ühe korra, seega kopeeri see turvalisse kohta.',
+    'user_api_token_created' => 'Tunnus lisatud :timeAgo',
+    'user_api_token_updated' => 'Tunnus muudetud :timeAgo',
+    'user_api_token_delete' => 'Kustuta tunnus',
+    'user_api_token_delete_warning' => 'See kustutab API tunnuse nimega \':tokenName\' süsteemist.',
+    'user_api_token_delete_confirm' => 'Kas oled kindel, et soovid selle API tunnuse kustutada?',
+    'user_api_token_delete_success' => 'API tunnus on kustutatud',
+
+    // Webhooks
+    'webhooks' => 'Veebihaagid',
+    'webhooks_create' => 'Lisa uus veebihaak',
+    'webhooks_none_created' => 'Ühtegi veebihaaki pole lisatud.',
+    'webhooks_edit' => 'Muuda veebihaaki',
+    'webhooks_save' => 'Salvesta veebihaak',
+    'webhooks_details' => 'Veebihaagi seaded',
+    'webhooks_details_desc' => 'Sisesta kasutajasõbralik nimi ja POST lõpp-punkt, kuhu veebihaagi andmeid saadetakse.',
+    'webhooks_events' => 'Veebihaagi sündmused',
+    'webhooks_events_desc' => 'Vali kõik sündmused, mille peale seda veebihaaki peaks käivitama.',
+    'webhooks_events_warning' => 'Pea meeles, et veebihaak käivitatakse kõigi valitud sündmuste peale, isegi kui on seatud kohandatud õigused. Hoolitse selle eest, et veebihaak ei teeks avalikuks konfidentsiaalset sisu.',
+    'webhooks_events_all' => 'Kõik süsteemsed sündmused',
+    'webhooks_name' => 'Veebihaagi nimi',
+    'webhooks_timeout' => 'Veebihaagi päringu aegumine (sekundit)',
+    'webhooks_endpoint' => 'Veebihaagi lõpp-punkt',
+    'webhooks_active' => 'Veebihaak aktiivne',
+    'webhook_events_table_header' => 'Sündmused',
+    'webhooks_delete' => 'Kustuta veebihaak',
+    'webhooks_delete_warning' => 'See kustutab veebihaagi nimega \':webhookName\' süsteemist.',
+    'webhooks_delete_confirm' => 'Kas oled kindel, et soovid selle veebihaagi kustutada?',
+    'webhooks_format_example' => 'Veebihaagi formaadi näidis',
+    'webhooks_format_example_desc' => 'Veebihaagi andmed saadetakse POST-päringuga seadistatud lõpp-punktile allpool toodud JSON-formaadis. Omadused "related_item" ja "url" on valikulised ja sõltuvad sündmusest, mis veebihaagi käivitas.',
+    'webhooks_status' => 'Veebihaagi staatus',
+    'webhooks_last_called' => 'Viimati käivitatud:',
+    'webhooks_last_errored' => 'Viimati ebaõnnestunud:',
+    'webhooks_last_error_message' => 'Viimane veateade:',
+
+
+    //! If editing translations files directly please ignore this in all
+    //! languages apart from en. Content will be auto-copied from en.
+    //!////////////////////////////////
+    'language_select' => [
+        'en' => 'English',
+        'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
+        'cs' => 'Česky',
+        'da' => 'Dansk',
+        'de' => 'Deutsch (Sie)',
+        'de_informal' => 'Deutsch (Du)',
+        'es' => 'Español',
+        'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
+        'fr' => 'Français',
+        'he' => 'עברית',
+        'hr' => 'Hrvatski',
+        'hu' => 'Magyar',
+        'id' => 'Bahasa Indonesia',
+        'it' => 'Italian',
+        'ja' => '日本語',
+        'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
+        'lv' => 'Latviešu Valoda',
+        'nl' => 'Nederlands',
+        'nb' => 'Norsk (Bokmål)',
+        'pl' => 'Polski',
+        'pt' => 'Português',
+        'pt_BR' => 'Português do Brasil',
+        'ru' => 'Русский',
+        'sk' => 'Slovensky',
+        'sl' => 'Slovenščina',
+        'sv' => 'Svenska',
+        'tr' => 'Türkçe',
+        'uk' => 'Українська',
+        'vi' => 'Tiếng Việt',
+        'zh_CN' => '简体中文',
+        'zh_TW' => '繁體中文',
+    ],
+    //!////////////////////////////////
+];
diff --git a/resources/lang/et/validation.php b/resources/lang/et/validation.php
new file mode 100644 (file)
index 0000000..ed615e6
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Validation Lines
+ * The following language lines contain the default error messages used by
+ * the validator class. Some of these rules have multiple versions such
+ * as the size rules. Feel free to tweak each of these messages here.
+ */
+return [
+
+    // Standard laravel validation lines
+    'accepted'             => ':attribute peab olema aktsepteeritud.',
+    'active_url'           => ':attribute ei ole kehtiv URL.',
+    'after'                => ':attribute peab olema kuupäev pärast :date.',
+    'alpha'                => ':attribute võib sisaldada ainult tähti.',
+    'alpha_dash'           => ':attribute võib sisaldada ainult tähti, numbreid, sidekriipse ja alakriipse.',
+    'alpha_num'            => ':attribute võib sisaldada ainult tähti ja numbreid.',
+    'array'                => ':attribute peab olema massiiv.',
+    'backup_codes'         => 'Kood ei ole korrektne või on seda juba kasutatud.',
+    'before'               => ':attribute peab olema kuupäev enne :date.',
+    'between'              => [
+        'numeric' => ':attribute peab jääma vahemikku :min ja :max.',
+        'file'    => ':attribute peab olema :min ja :max kilobaidi vahel.',
+        'string'  => ':attribute peab olema :min ja :max tähemärgi vahel.',
+        'array'   => ':attribute peab olema :min ja :max elemendi vahel.',
+    ],
+    'boolean'              => ':attribute peab olema tõene või väär.',
+    'confirmed'            => ':attribute kinnitus ei kattu.',
+    'date'                 => ':attribute ei ole kehtiv kuupäev.',
+    'date_format'          => ':attribute ei ühti formaadiga :format.',
+    'different'            => ':attribute ja :other peavad olema erinevad.',
+    'digits'               => ':attribute peab olema :digits-kohaline arv.',
+    'digits_between'       => ':attribute peab olema :min ja :max numbri vahel.',
+    'email'                => ':attribute peab olema kehtiv e-posti aadress.',
+    'ends_with' => ':attribute lõpus peab olema üks järgmistest väärtustest: :values',
+    'filled'               => ':attribute väli on kohustuslik.',
+    'gt'                   => [
+        'numeric' => ':attribute peab olema suurem kui :value.',
+        'file'    => ':attribute peab olema suurem kui :value kilobaiti.',
+        'string'  => ':attribute peab sisaldama rohkem kui :value tähemärki.',
+        'array'   => ':attribute peab sisaldama rohkem kui :value elementi.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute peab olema suurem kui või võrdne :value.',
+        'file'    => ':attribute peab olema :value kilobaiti või rohkem.',
+        'string'  => ':attribute peab sisaldama :value või rohkem tähemärki.',
+        'array'   => ':attribute peab sisaldama :value või rohkem elementi.',
+    ],
+    'exists'               => 'Valitud :attribute on vigane.',
+    'image'                => ':attribute peab olema pildifail.',
+    'image_extension'      => ':attribute peab olema lubatud ja toetatud pildiformaadis.',
+    'in'                   => 'Valitud :attribute on vigane.',
+    'integer'              => ':attribute peab olema täisarv.',
+    'ip'                   => ':attribute peab olema kehtiv IP-aadress.',
+    'ipv4'                 => ':attribute peab olema kehtiv IPv4 aadress.',
+    'ipv6'                 => ':attribute peab olema kehtiv IPv6 aadress.',
+    'json'                 => ':attribute peab olema kehtiv JSON-vormingus tekst.',
+    'lt'                   => [
+        'numeric' => ':attribute peab olema väiksem kui :value.',
+        'file'    => ':attribute peab olema väiksem kui :value kilobaiti.',
+        'string'  => ':attribute peab sisaldama vähem kui :value tähemärki.',
+        'array'   => ':attribute peab sisaldama vähem kui :value elementi.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute peab olema :value või vähem.',
+        'file'    => ':attribute peab olema :value kilobaiti või vähem.',
+        'string'  => ':attribute peab sisaldama :value või vähem tähemärki.',
+        'array'   => ':attribute ei tohi sisaldada rohkem kui :value elementi.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute ei tohi olla suurem kui :max.',
+        'file'    => ':attribute ei tohi olla suurem kui :max kilobaiti.',
+        'string'  => ':attribute ei tohi sisaldada rohkem kui :max tähemärki.',
+        'array'   => ':attribute ei tohi sisaldada rohkem kui :max elementi.',
+    ],
+    'mimes'                => ':attribute peab olema seda tüüpi fail: :values.',
+    'min'                  => [
+        'numeric' => ':attribute peab olema vähemalt :min.',
+        'file'    => ':attribute peab olema vähemalt :min kilobaiti.',
+        'string'  => ':attribute peab sisaldama vähemalt :min tähemärki.',
+        'array'   => ':attribute peab sisaldama vähemalt :min elementi.',
+    ],
+    'not_in'               => 'Valitud :attribute on vigane.',
+    'not_regex'            => ':attribute on vigases formaadis.',
+    'numeric'              => ':attribute peab olema arv.',
+    'regex'                => ':attribute on vigases formaadis.',
+    'required'             => ':attribute on kohustuslik.',
+    'required_if'          => ':attribute on kohustuslik, kui :other on :value.',
+    'required_with'        => ':attribute on kohustuslik, kui :values on olemas.',
+    'required_with_all'    => ':attribute on kohustuslik, kui :values on olemas.',
+    'required_without'     => ':attribute on kohustuslik, kui :values ei ole olemas.',
+    'required_without_all' => ':attribute on kohustuslik, kui :values on valimata.',
+    'same'                 => ':attribute ja :other peavad klappima.',
+    'safe_url'             => 'Link ei pruugi olla turvaline.',
+    'size'                 => [
+        'numeric' => ':attribute peab olema :size.',
+        'file'    => ':attribute peab olema :size kilobaiti.',
+        'string'  => ':attribute peab sisaldama :size tähemärki.',
+        'array'   => ':attribute peab sisaldama :size elemente.',
+    ],
+    'string'               => ':attribute peab olema string.',
+    'timezone'             => ':attribute peab olema kehtiv ajavöönd.',
+    'totp'                 => 'Kood ei ole korrektne või on aegunud.',
+    'unique'               => ':attribute on juba võetud.',
+    'url'                  => ':attribute on vigases formaadis.',
+    'uploaded'             => 'Faili üleslaadimine ebaõnnestus. Server ei pruugi sellise suurusega faile vastu võtta.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Parooli kinnitus on nõutud',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index fe937b061930262a060465699446647adab763d9..4a4d40768110925fcbe5fdaf3efc38c45a9eca73 100644 (file)
@@ -6,44 +6,60 @@
 return [
 
     // Pages
-    'page_create'                 => 'created page',
-    'page_create_notification'    => 'Page Successfully Created',
-    'page_update'                 => 'updated page',
-    'page_update_notification'    => 'Page Successfully Updated',
-    'page_delete'                 => 'deleted page',
-    'page_delete_notification'    => 'Page Successfully Deleted',
-    'page_restore'                => 'restored page',
-    'page_restore_notification'   => 'Page Successfully Restored',
-    'page_move'                   => 'moved page',
+    'page_create'                 => 'صفحه ایجاد شده',
+    'page_create_notification'    => 'صفحه با موفقیت ایجاد شد',
+    'page_update'                 => 'صفحه بروز شده',
+    'page_update_notification'    => 'صفحه با موفقیت به روزرسانی شد',
+    'page_delete'                 => 'حذف صفحه',
+    'page_delete_notification'    => 'صفحه با موفقیت حذف شد',
+    'page_restore'                => 'بازیابی صفحه',
+    'page_restore_notification'   => 'صفحه با موفقیت بازیابی شد',
+    'page_move'                   => 'انتقال صفحه',
 
     // Chapters
-    'chapter_create'              => 'created chapter',
-    'chapter_create_notification' => 'Chapter Successfully Created',
-    'chapter_update'              => 'updated chapter',
-    'chapter_update_notification' => 'Chapter Successfully Updated',
-    'chapter_delete'              => 'deleted chapter',
-    'chapter_delete_notification' => 'Chapter Successfully Deleted',
-    'chapter_move'                => 'moved chapter',
+    'chapter_create'              => 'ایجاد فصل',
+    'chapter_create_notification' => 'فصل با موفقیت ایجاد شد',
+    'chapter_update'              => 'به روزرسانی فصل',
+    'chapter_update_notification' => 'فصل با موفقیت به روزرسانی شد',
+    'chapter_delete'              => 'حذف فصل',
+    'chapter_delete_notification' => 'فصل با موفقیت حذف شد',
+    'chapter_move'                => 'انتقال فصل',
 
     // Books
-    'book_create'                 => 'created book',
-    'book_create_notification'    => 'Book Successfully Created',
-    'book_update'                 => 'updated book',
-    'book_update_notification'    => 'Book Successfully Updated',
-    'book_delete'                 => 'deleted book',
-    'book_delete_notification'    => 'Book Successfully Deleted',
-    'book_sort'                   => 'sorted book',
-    'book_sort_notification'      => 'Book Successfully Re-sorted',
+    'book_create'                 => 'ایجاد کتاب',
+    'book_create_notification'    => 'کتاب با موفقیت ایجاد شد',
+    'book_update'                 => 'به روزرسانی کتاب',
+    'book_update_notification'    => 'کتاب با موفقیت به روزرسانی شد',
+    'book_delete'                 => 'حذف کتاب',
+    'book_delete_notification'    => 'کتاب با موفقیت حذف شد',
+    'book_sort'                   => 'مرتب سازی کتاب',
+    'book_sort_notification'      => 'کتاب با موفقیت مرتب سازی شد',
 
     // Bookshelves
-    'bookshelf_create'            => 'created Bookshelf',
-    'bookshelf_create_notification'    => 'Bookshelf Successfully Created',
-    'bookshelf_update'                 => 'updated bookshelf',
-    'bookshelf_update_notification'    => 'Bookshelf Successfully Updated',
-    'bookshelf_delete'                 => 'deleted bookshelf',
-    'bookshelf_delete_notification'    => 'Bookshelf Successfully Deleted',
+    'bookshelf_create'            => 'ایجاد قفسه کتاب',
+    'bookshelf_create_notification'    => 'قفسه کتاب با موفقیت ایجاد شد',
+    'bookshelf_update'                 => 'به روزرسانی قفسه کتاب',
+    'bookshelf_update_notification'    => 'قفسه کتاب با موفقیت به روزرسانی شد',
+    'bookshelf_delete'                 => 'حذف قفسه کتاب',
+    'bookshelf_delete_notification'    => 'قفسه کتاب با موفقیت حذف شد',
+
+    // Favourites
+    'favourite_add_notification' => '":name" به علاقه مندی های شما اضافه شد',
+    'favourite_remove_notification' => '":name" از علاقه مندی های شما حذف شد',
+
+    // MFA
+    'mfa_setup_method_notification' => 'روش چند فاکتوری با موفقیت پیکربندی شد',
+    'mfa_remove_method_notification' => 'روش چند فاکتوری با موفقیت حذف شد',
+
+    // Webhooks
+    'webhook_create' => 'ایجاد وب هوک',
+    'webhook_create_notification' => 'وب هوک با موفقیت ایجاد شد',
+    'webhook_update' => 'به روزرسانی وب هوک',
+    'webhook_update_notification' => 'وب هوک با موفقیت بروزرسانی شد',
+    'webhook_delete' => 'حذف وب هوک',
+    'webhook_delete_notification' => 'وب هوک با موفقیت حذف شد',
 
     // Other
-    'commented_on'                => 'commented on',
-    'permissions_update'          => 'updated permissions',
+    'commented_on'                => 'ثبت دیدگاه',
+    'permissions_update'          => 'به روزرسانی مجوزها',
 ];
index d64fce93a62d90889b2297a9e4f6482ad9046475..9555f9a08ba33ca24afbfe4f89defca7d6504201 100644 (file)
  */
 return [
 
-    'failed' => 'These credentials do not match our records.',
-    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+    'failed' => 'مشخصات وارد شده با اطلاعات ما سازگار نیست.',
+    'throttle' => 'دفعات تلاش شما برای ورود بیش از حد مجاز است. لطفا پس از :seconds ثانیه مجددا تلاش فرمایید.',
 
     // Login & Register
-    'sign_up' => 'Sign up',
-    'log_in' => 'Log in',
-    'log_in_with' => 'Login with :socialDriver',
-    'sign_up_with' => 'Sign up with :socialDriver',
-    'logout' => 'Logout',
+    'sign_up' => 'ثبت نام',
+    'log_in' => 'ورود',
+    'log_in_with' => 'ورود با :socialDriver',
+    'sign_up_with' => 'ثبت نام با :socialDriver',
+    'logout' => 'خروج',
 
-    'name' => 'Name',
-    'username' => 'Username',
-    'email' => 'Email',
-    'password' => 'Password',
-    'password_confirm' => 'Confirm Password',
-    'password_hint' => 'Must be over 7 characters',
-    'forgot_password' => 'Forgot Password?',
-    'remember_me' => 'Remember Me',
-    'ldap_email_hint' => 'Please enter an email to use for this account.',
-    'create_account' => 'Create Account',
-    'already_have_account' => 'Already have an account?',
-    'dont_have_account' => 'Don\'t have an account?',
-    'social_login' => 'Social Login',
-    'social_registration' => 'Social Registration',
-    'social_registration_text' => 'Register and sign in using another service.',
-
-    'register_thanks' => 'Thanks for registering!',
-    'register_confirm' => 'Please check your email and click the confirmation button to access :appName.',
-    'registrations_disabled' => 'Registrations are currently disabled',
-    'registration_email_domain_invalid' => 'That email domain does not have access to this application',
-    'register_success' => 'Thanks for signing up! You are now registered and signed in.',
+    'name' => 'نام',
+    'username' => 'نام کاربری',
+    'email' => 'پست الکترونیک',
+    'password' => 'کلمه عبور',
+    'password_confirm' => 'تایید کلمه عبور',
+    'password_hint' => 'باید بیش از 8 کاراکتر باشد',
+    'forgot_password' => 'کلمه عبور خود را فراموش کرده اید؟',
+    'remember_me' => 'مرا به خاطر بسپار',
+    'ldap_email_hint' => 'لطفا برای استفاده از این حساب کاربری پست الکترونیک وارد نمایید.',
+    'create_account' => 'ایجاد حساب کاربری',
+    'already_have_account' => 'قبلا ثبت نام نموده اید؟',
+    'dont_have_account' => 'حساب کاربری ندارید؟',
+    'social_login' => 'ورود از طریق شبکه اجتماعی',
+    'social_registration' => 'ثبت نام از طریق شبکه اجتماعی',
+    'social_registration_text' => 'با استفاده از سرویس دیگری ثبت نام نموده و وارد سیستم شوید.',
 
+    'register_thanks' => 'از ثبت نام شما متشکریم!',
+    'register_confirm' => 'لطفا پست الکترونیک خود را بررسی نموده و برای دسترسی به:appName دکمه تایید را کلیک نمایید.',
+    'registrations_disabled' => 'ثبت نام در حال حاضر غیر فعال است',
+    'registration_email_domain_invalid' => 'دامنه پست الکترونیک به این برنامه دسترسی ندارد',
+    'register_success' => 'از ثبت نام شما سپاسگزاریم! شما اکنون ثبت نام کرده و وارد سیستم شده اید.',
 
     // Password Reset
-    '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.',
-    '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.',
-
+    'reset_password' => 'بازنشانی کلمه عبور',
+    'reset_password_send_instructions' => 'پست الکترونیک خود را در کادر زیر وارد نموده تا یک پیام حاوی لینک بازنشانی کلمه عبور دریافت نمایید.',
+    'reset_password_send_button' => 'ارسال لینک بازنشانی',
+    'reset_password_sent' => 'در صورت موجود بودن پست الکترونیک، یک لینک بازنشانی کلمه عبور برای شما ارسال خواهد شد.',
+    'reset_password_success' => 'کلمه عبور شما با موفقیت بازنشانی شد.',
+    'email_reset_subject' => 'بازنشانی کلمه عبور :appName',
+    'email_reset_text' => 'شما این پیام را به علت درخواست بازنشانی کلمه عبور دریافت می نمایید.',
+    'email_reset_not_requested' => 'در صورتی که درخواست بازنشانی کلمه عبور از سمت شما نمی باشد، نیاز به انجام هیچ فعالیتی ندارید.',
 
     // Email Confirmation
-    'email_confirm_subject' => 'Confirm your email on :appName',
-    'email_confirm_greeting' => 'Thanks for joining :appName!',
-    'email_confirm_text' => 'Please confirm your email address by clicking the button below:',
-    'email_confirm_action' => 'Confirm Email',
-    'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.',
-    'email_confirm_success' => 'Your email has been confirmed!',
-    'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.',
+    'email_confirm_subject' => 'پست الکترونیک خود را در:appName تایید نمایید',
+    'email_confirm_greeting' => 'برای پیوستن به :appName متشکریم!',
+    'email_confirm_text' => 'لطفا با کلیک بر روی دکمه زیر پست الکترونیک خود را تایید نمایید:',
+    'email_confirm_action' => 'تایید پست الکترونیک',
+    'email_confirm_send_error' => 'تایید پست الکترونیک الزامی می باشد، اما سیستم قادر به ارسال پیام نمی باشد.',
+    'email_confirm_success' => 'ایمیل شما تایید شد! اکنون باید بتوانید با استفاده از این آدرس ایمیل وارد شوید.',
+    'email_confirm_resent' => 'پیام تایید پست الکترونیک مجدد ارسال گردید، لطفا صندوق ورودی خود را بررسی نمایید.',
 
-    'email_not_confirmed' => 'Email Address Not Confirmed',
-    'email_not_confirmed_text' => 'Your email address has not yet been confirmed.',
-    'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.',
-    'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.',
-    'email_not_confirmed_resend_button' => 'Resend Confirmation Email',
+    'email_not_confirmed' => 'پست الکترونیک تایید نشده است',
+    'email_not_confirmed_text' => 'پست الکترونیک شما هنوز تایید نشده است.',
+    'email_not_confirmed_click_link' => 'لطفا بر روی لینک موجود در پیامی که بلافاصله پس از ثبت نام ارسال شده است کلیک نمایید.',
+    'email_not_confirmed_resend' => 'در صورتی که نمی توانید پیام را پیدا کنید، می توانید با ارسال فرم زیر، پیام تایید را مجدد دریافت نمایید.',
+    'email_not_confirmed_resend_button' => 'ارسال مجدد تایید پست الکترونیک',
 
     // User Invite
-    'user_invite_email_subject' => 'You have been invited to join :appName!',
-    'user_invite_email_greeting' => 'An account has been created for you on :appName.',
-    'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
-    'user_invite_email_action' => 'Set Account Password',
-    'user_invite_page_welcome' => 'Welcome to :appName!',
-    'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
-    'user_invite_page_confirm_button' => 'Confirm Password',
-    'user_invite_success' => 'Password set, you now have access to :appName!'
-];
\ No newline at end of file
+    'user_invite_email_subject' => 'از شما برای پیوستن به :appName دعوت شده است!',
+    'user_invite_email_greeting' => 'حساب کاربری برای شما در :appName ایجاد شده است.',
+    'user_invite_email_text' => 'برای تنظیم کلمه عبور و دسترسی به حساب کاربری بر روی دکمه زیر کلیک نمایید:',
+    'user_invite_email_action' => 'تنظیم کلمه عبور حساب‌کاربری',
+    'user_invite_page_welcome' => 'به :appName خوش آمدید!',
+    'user_invite_page_text' => 'برای نهایی کردن حساب کاربری خود در :appName و دسترسی به آن، می بایست یک کلمه عبور تنظیم نمایید.',
+    'user_invite_page_confirm_button' => 'تایید کلمه عبور',
+    'user_invite_success_login' => 'رمز عبور تنظیم شده است، اکنون باید بتوانید با استفاده از رمز عبور تعیین شده خود وارد شوید تا به :appName دسترسی پیدا کنید!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'تنظیم احراز هویت چند مرحله‌ای',
+    'mfa_setup_desc' => 'تنظیم احراز هویت چند مرحله ای یک لایه امنیتی دیگر به حساب شما اضافه میکند.',
+    'mfa_setup_configured' => 'هم اکنون تنظیم شده است.',
+    'mfa_setup_reconfigure' => 'تنظیم مجدد',
+    'mfa_setup_remove_confirmation' => 'از حذف احراز هویت چند مرحله ای اطمینان دارید؟',
+    'mfa_setup_action' => 'تنظیم',
+    'mfa_backup_codes_usage_limit_warning' => 'کمتر از 5 کد پشتیبان باقی مانده است، لطفاً قبل از تمام شدن کدها یک مجموعه جدید ایجاد و ذخیره کنید تا از قفل شدن حساب خود جلوگیری کنید.',
+    'mfa_option_totp_title' => 'برنامه ی موبایل',
+    'mfa_option_totp_desc' => 'برای استفاده از احراز هویت چند عاملی به یک برنامه موبایلی نیاز دارید که از TOTP پشتیبانی کند، مانند Google Authenticator، Authy یا Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'کدهای پشتیبان',
+    'mfa_option_backup_codes_desc' => 'مجموعه ای از کدهای پشتیبان یکبار مصرف را ایمن ذخیره کنید که می توانید برای تأیید هویت خود وارد کنید.',
+    'mfa_gen_confirm_and_enable' => 'تایید و فعال کنید',
+    'mfa_gen_backup_codes_title' => 'راه اندازی کدهای پشتیبان',
+    'mfa_gen_backup_codes_desc' => 'لیست کدهای زیر را در مکانی امن ذخیره کنید. هنگام دسترسی به سیستم، می توانید از یکی از کدها به عنوان مکانیزم احراز هویت دوم استفاده کنید.',
+    'mfa_gen_backup_codes_download' => 'دانلود کدها',
+    'mfa_gen_backup_codes_usage_warning' => 'هر کد فقط یک بار قابل استفاده است',
+    'mfa_gen_totp_title' => 'راه اندازی اپلیکیشن موبایل',
+    'mfa_gen_totp_desc' => 'برای استفاده از احراز هویت چند عاملی به یک برنامه موبایلی نیاز دارید که از TOTP پشتیبانی کند، مانند Google Authenticator، Authy یا Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'برای شروع، کد QR زیر را با استفاده از برنامه احراز هویت ترجیحی خود اسکن کنید.',
+    'mfa_gen_totp_verify_setup' => 'تأیید تنظیمات',
+    'mfa_gen_totp_verify_setup_desc' => 'با وارد کردن کدی که در برنامه احراز هویت شما ایجاد شده است، در کادر ورودی زیر، مطمئن شوید که همه کار می کنند:',
+    'mfa_gen_totp_provide_code_here' => 'کد تولید شده برنامه خود را در اینجا ارائه دهید',
+    'mfa_verify_access' => 'تأیید دسترسی',
+    'mfa_verify_access_desc' => 'قبل از اینکه به شما اجازه دسترسی داده شود، حساب کاربری شما از شما می خواهد که هویت خود را از طریق یک سطح تأیید اضافی تأیید کنید. برای ادامه، با استفاده از یکی از روش های پیکربندی شده خود، تأیید کنید.',
+    'mfa_verify_no_methods' => 'هیچ روشی پیکربندی نشده است',
+    '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' => 'روش چند عاملی پیکربندی شد، لطفاً اکنون دوباره با استفاده از روش پیکربندی شده وارد شوید.',
+];
index 855c1c807488a8af9860f35f24004e0bb055edc1..0e4a5b6bf10cadc98dafaf0a59ad216187996975 100644 (file)
@@ -5,85 +5,98 @@
 return [
 
     // Buttons
-    'cancel' => 'Cancel',
-    'confirm' => 'Confirm',
-    'back' => 'Back',
-    'save' => 'Save',
-    'continue' => 'Continue',
-    'select' => 'Select',
-    'toggle_all' => 'Toggle All',
-    'more' => 'More',
+    'cancel' => 'لغو',
+    'confirm' => 'تایید',
+    'back' => 'بازگشت',
+    'save' => 'ذخیره',
+    'continue' => 'ادامه',
+    'select' => 'انتخاب',
+    'toggle_all' => 'معکوس کردن همه',
+    'more' => 'بیشتر',
 
     // Form Labels
-    'name' => 'Name',
-    'description' => 'Description',
-    'role' => 'Role',
-    'cover_image' => 'Cover image',
-    'cover_image_description' => 'This image should be approx 440x250px.',
-    
+    'name' => 'نام',
+    'description' => 'توضیحات',
+    'role' => 'نقش',
+    'cover_image' => 'تصویر روی جلد',
+    'cover_image_description' => 'سایز تصویر باید 440x250 باشد.',
+
     // Actions
-    'actions' => 'Actions',
-    'view' => 'View',
-    'view_all' => 'View All',
-    'create' => 'Create',
-    'update' => 'Update',
-    'edit' => 'Edit',
-    'sort' => 'Sort',
-    'move' => 'Move',
-    'copy' => 'Copy',
-    'reply' => 'Reply',
-    'delete' => 'Delete',
-    'delete_confirm' => 'Confirm Deletion',
-    'search' => 'Search',
-    'search_clear' => 'Clear Search',
-    'reset' => 'Reset',
-    'remove' => 'Remove',
-    'add' => 'Add',
-    'fullscreen' => 'Fullscreen',
+    'actions' => 'عملیات',
+    'view' => 'نمایش',
+    'view_all' => 'نمایش همه',
+    'create' => 'ایجاد',
+    'update' => 'به‌روز رسانی',
+    'edit' => 'ويرايش',
+    'sort' => 'مرتب سازی',
+    'move' => 'جابجایی',
+    'copy' => 'کپی',
+    'reply' => 'پاسخ',
+    'delete' => 'حذف',
+    'delete_confirm' => 'تأیید حذف',
+    'search' => 'جستجو',
+    'search_clear' => 'پاک کردن جستجو',
+    'reset' => 'بازنشانی',
+    'remove' => 'حذف',
+    'add' => 'ﺍﻓﺰﻭﺩﻥ',
+    'configure' => 'پیکربندی کنید',
+    'fullscreen' => 'تمام صفحه',
+    'favourite' => 'علاقه‌مندی',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'بعدی',
+    'previous' => 'قبلى',
+    'filter_active' => 'فیلتر فعال:',
+    'filter_clear' => 'پاک کردن فیلتر',
 
     // Sort Options
-    'sort_options' => 'Sort Options',
-    'sort_direction_toggle' => 'Sort Direction Toggle',
-    'sort_ascending' => 'Sort Ascending',
-    'sort_descending' => 'Sort Descending',
-    'sort_name' => 'Name',
-    'sort_default' => 'Default',
-    'sort_created_at' => 'Created Date',
-    'sort_updated_at' => 'Updated Date',
+    'sort_options' => 'گزینه‌های مرتب سازی',
+    'sort_direction_toggle' => 'معکوس کردن جهت مرتب سازی',
+    'sort_ascending' => 'مرتب‌سازی صعودی',
+    'sort_descending' => 'مرتب‌سازی نزولی',
+    'sort_name' => 'نام',
+    'sort_default' => 'پیش‎فرض',
+    'sort_created_at' => 'تاریخ ایجاد',
+    'sort_updated_at' => 'تاریخ بروزرسانی',
 
     // Misc
-    'deleted_user' => 'Deleted User',
-    'no_activity' => 'No activity to show',
-    'no_items' => 'No items available',
-    'back_to_top' => 'Back to top',
-    'toggle_details' => 'Toggle Details',
-    'toggle_thumbnails' => 'Toggle Thumbnails',
-    'details' => 'Details',
-    'grid_view' => 'Grid View',
-    'list_view' => 'List View',
-    'default' => 'Default',
-    'breadcrumb' => 'Breadcrumb',
+    'deleted_user' => 'کاربر حذف شده',
+    'no_activity' => 'بایگانی برای نمایش وجود ندارد',
+    'no_items' => 'هیچ آیتمی موجود نیست',
+    'back_to_top' => 'بازگشت به بالا',
+    'skip_to_main_content' => 'رفتن به محتوای اصلی',
+    'toggle_details' => 'معکوس کردن اطلاعات',
+    'toggle_thumbnails' => 'معکوس ریز عکس ها',
+    'details' => 'جزییات',
+    'grid_view' => 'نمایش شبکه‌ای',
+    'list_view' => 'نمای لیست',
+    'default' => 'پیش‎فرض',
+    'breadcrumb' => 'مسیر جاری',
+    'status' => 'وضعیت',
+    'status_active' => 'فعال',
+    'status_inactive' => 'غیر فعال',
+    'never' => 'هرگز',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
-    'profile_menu' => 'Profile Menu',
-    'view_profile' => 'View Profile',
-    'edit_profile' => 'Edit Profile',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'header_menu_expand' => 'گسترش منو',
+    'profile_menu' => 'منو پروفایل',
+    'view_profile' => 'مشاهده پروفایل',
+    'edit_profile' => 'ویرایش پروفایل',
+    'dark_mode' => 'حالت تاریک',
+    'light_mode' => 'حالت روشن',
 
     // Layout tabs
-    'tab_info' => 'Info',
-    'tab_info_label' => 'Tab: Show Secondary Information',
-    'tab_content' => 'Content',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_info' => 'اطلاعات',
+    'tab_info_label' => 'زبانه: نمایش اطلاعات ثانویه',
+    'tab_content' => 'محتوا',
+    'tab_content_label' => 'زبانه: نمایش محتوای اصلی',
 
     // Email Content
-    'email_action_help' => 'If you’re having trouble clicking the ":actionText" button, copy and paste the URL below into your web browser:',
-    'email_rights' => 'All rights reserved',
+    'email_action_help' => 'اگر با دکمه بالا مشکلی دارید ، ادرس وبسایت *URLزیر را در مرورگر وب خود کپی و پیست کنید:',
+    'email_rights' => 'تمام حقوق محفوظ است',
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'سیاست حفظ حریم خصوصی',
+    'terms_of_service' => 'شرایط خدمات',
 ];
index 48a0a32faa38c4821a9d71dda9a5fb4f97d35232..126bd093db9854d58c2d6b2718b86b605d4e996a 100644 (file)
@@ -5,30 +5,30 @@
 return [
 
     // Image Manager
-    'image_select' => 'Image Select',
-    'image_all' => 'All',
-    'image_all_title' => 'View all images',
-    'image_book_title' => 'View images uploaded to this book',
-    'image_page_title' => 'View images uploaded to this page',
-    'image_search_hint' => 'Search by image name',
-    'image_uploaded' => 'Uploaded :uploadedDate',
-    'image_load_more' => 'Load More',
-    'image_image_name' => 'Image Name',
-    'image_delete_used' => 'This image is used in the pages below.',
-    'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
-    'image_select_image' => 'Select Image',
-    'image_dropzone' => 'Drop images or click here to upload',
-    'images_deleted' => 'Images Deleted',
-    'image_preview' => 'Image Preview',
-    'image_upload_success' => 'Image uploaded successfully',
-    'image_update_success' => 'Image details successfully updated',
-    'image_delete_success' => 'Image successfully deleted',
-    'image_upload_remove' => 'Remove',
+    'image_select' => 'انتخاب تصویر',
+    'image_all' => 'همه',
+    'image_all_title' => 'نمایش تمام تصاویر',
+    'image_book_title' => 'تصاویر بارگذاری شده در این کتاب را مشاهده کنید',
+    'image_page_title' => 'تصاویر بارگذاری شده در این صفحه را مشاهده کنید',
+    'image_search_hint' => 'جستجو بر اساس نام تصویر',
+    'image_uploaded' => 'بارگذاری شده :uploadedDate',
+    'image_load_more' => 'بارگذاری بیشتر',
+    'image_image_name' => 'نام تصویر',
+    'image_delete_used' => 'این تصویر در صفحات زیر استفاده شده است.',
+    'image_delete_confirm_text' => 'آیا مطمئن هستید که میخواهید این عکس را پاک کنید؟',
+    'image_select_image' => 'انتخاب تصویر',
+    'image_dropzone' => 'تصاویر را رها کنید یا برای بارگذاری اینجا را کلیک کنید',
+    'images_deleted' => 'تصاویر حذف شده',
+    'image_preview' => 'پیش نمایش تصویر',
+    'image_upload_success' => 'تصویر با موفقیت بارگذاری شد',
+    'image_update_success' => 'جزئیات تصویر با موفقیت به روز شد',
+    'image_delete_success' => 'تصویر با موفقیت حذف شد',
+    'image_upload_remove' => 'حذف',
 
     // Code Editor
-    'code_editor' => 'Edit Code',
-    'code_language' => 'Code Language',
-    'code_content' => 'Code Content',
-    'code_session_history' => 'Session History',
-    'code_save' => 'Save Code',
+    'code_editor' => 'ویرایش کد',
+    'code_language' => 'زبان کد',
+    'code_content' => 'محتوی کد',
+    'code_session_history' => 'تاریخچه جلسات',
+    'code_save' => 'ذخیره کد',
 ];
index 1661bae57cae5184af9381b15f79fe0c301d003a..0a335b1180cc927fa0330187ab0029761bbbc63a 100644 (file)
 return [
 
     // Shared
-    'recently_created' => 'Recently Created',
-    'recently_created_pages' => 'Recently Created Pages',
-    'recently_updated_pages' => 'Recently Updated Pages',
-    'recently_created_chapters' => 'Recently Created Chapters',
-    'recently_created_books' => 'Recently Created Books',
-    'recently_created_shelves' => 'Recently Created Shelves',
-    'recently_update' => 'Recently Updated',
-    'recently_viewed' => 'Recently Viewed',
-    'recent_activity' => 'Recent Activity',
-    'create_now' => 'Create one now',
-    'revisions' => 'Revisions',
-    'meta_revision' => 'Revision #:revisionCount',
-    'meta_created' => 'Created :timeLength',
-    'meta_created_name' => 'Created :timeLength by :user',
-    'meta_updated' => 'Updated :timeLength',
-    'meta_updated_name' => 'Updated :timeLength by :user',
-    'meta_owned_name' => 'Owned by :user',
-    'entity_select' => 'Entity Select',
-    'images' => 'Images',
-    'my_recent_drafts' => 'My Recent Drafts',
-    'my_recently_viewed' => 'My Recently Viewed',
-    'no_pages_viewed' => 'You have not viewed any pages',
-    'no_pages_recently_created' => 'No pages have been recently created',
-    'no_pages_recently_updated' => 'No pages have been recently updated',
-    'export' => 'Export',
-    'export_html' => 'Contained Web File',
-    'export_pdf' => 'PDF File',
-    'export_text' => 'Plain Text File',
+    'recently_created' => 'اخیرا ایجاد شده',
+    'recently_created_pages' => 'صفحات اخیرا ایجاد شده',
+    'recently_updated_pages' => 'صفحاتی که اخیرا روزآمد شده‌اند',
+    'recently_created_chapters' => 'فصل های اخیرا ایجاد شده',
+    'recently_created_books' => 'کتاب های اخیرا ایجاد شده',
+    'recently_created_shelves' => 'قفسه کتاب های اخیرا ایجاد شده',
+    'recently_update' => 'اخیرا به روز شده',
+    'recently_viewed' => 'اخیرا مشاهده شده',
+    'recent_activity' => 'فعالیت های اخیر',
+    'create_now' => 'اکنون یکی ایجاد کنید',
+    'revisions' => 'بازبینی‌ها',
+    'meta_revision' => 'بازبینی #:revisionCount',
+    'meta_created' => 'ایجاد شده :timeLength',
+    'meta_created_name' => 'ایجاد شده :timeLength توسط :user',
+    'meta_updated' => 'به روزرسانی شده :timeLength',
+    'meta_updated_name' => 'به روزرسانی شده :timeLength توسط :user',
+    'meta_owned_name' => 'توسط :user ایجاد شده‌است',
+    'entity_select' => 'انتخاب موجودیت',
+    'images' => 'عکس ها',
+    'my_recent_drafts' => 'پیش نویس های اخیر من',
+    'my_recently_viewed' => 'بازدیدهای اخیر من',
+    'my_most_viewed_favourites' => 'محبوب ترین موارد مورد علاقه من',
+    'my_favourites' => 'مورد علاقه من',
+    'no_pages_viewed' => 'شما هیچ صفحه ای را مشاهده نکرده اید',
+    'no_pages_recently_created' => 'اخیرا هیچ صفحه ای ایجاد نشده است',
+    'no_pages_recently_updated' => 'اخیرا هیچ صفحه ای به روزرسانی نشده است',
+    'export' => 'خروجی',
+    'export_html' => 'فایل وب موجود است',
+    'export_pdf' => 'فایل PDF',
+    'export_text' => 'پرونده متنی ساده',
+    'export_md' => 'راهنما مارک‌دون',
 
     // Permissions and restrictions
-    'permissions' => 'Permissions',
-    'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.',
-    'permissions_enable' => 'Enable Custom Permissions',
-    'permissions_save' => 'Save Permissions',
-    'permissions_owner' => 'Owner',
+    'permissions' => 'مجوزها',
+    'permissions_intro' => 'پس از فعال شدن، این مجوزها نسبت به مجوزهای تعیین شده نقش اولویت دارند.',
+    'permissions_enable' => 'مجوزهای سفارشی را فعال کنید',
+    'permissions_save' => 'ذخيره مجوزها',
+    'permissions_owner' => 'مالک',
 
     // Search
-    'search_results' => 'Search Results',
+    'search_results' => 'نتایج جستجو',
     'search_total_results_found' => ':count result found|:count total results found',
-    'search_clear' => 'Clear Search',
-    'search_no_pages' => 'No pages matched this search',
-    'search_for_term' => 'Search for :term',
-    'search_more' => 'More Results',
-    'search_advanced' => 'Advanced Search',
-    'search_terms' => 'Search Terms',
-    'search_content_type' => 'Content Type',
-    'search_exact_matches' => 'Exact Matches',
-    'search_tags' => 'Tag Searches',
-    'search_options' => 'Options',
-    'search_viewed_by_me' => 'Viewed by me',
-    'search_not_viewed_by_me' => 'Not viewed by me',
-    'search_permissions_set' => 'Permissions set',
-    'search_created_by_me' => 'Created by me',
-    'search_updated_by_me' => 'Updated by me',
-    'search_owned_by_me' => 'Owned by me',
-    'search_date_options' => 'Date Options',
-    'search_updated_before' => 'Updated before',
-    'search_updated_after' => 'Updated after',
-    'search_created_before' => 'Created before',
-    'search_created_after' => 'Created after',
-    'search_set_date' => 'Set Date',
-    'search_update' => 'Update Search',
+    'search_clear' => 'پاک کردن جستجو',
+    'search_no_pages' => 'هیچ صفحه ای با این جستجو مطابقت ندارد',
+    'search_for_term' => 'جستجو برای :term',
+    'search_more' => 'نتایج بیشتر',
+    'search_advanced' => 'جستجوی پیشرفته',
+    'search_terms' => 'عبارات جستجو',
+    'search_content_type' => 'نوع محتوا',
+    'search_exact_matches' => 'مطابقت کامل',
+    'search_tags' => 'جستجوها را برچسب بزنید',
+    'search_options' => 'گزینه ها',
+    'search_viewed_by_me' => 'بازدید شده به وسیله من',
+    'search_not_viewed_by_me' => 'توسط من مشاهده نشده است',
+    'search_permissions_set' => 'مجوزها تنظیم شده است',
+    'search_created_by_me' => 'ایجاد شده توسط من',
+    'search_updated_by_me' => 'به روز شده توسط من',
+    'search_owned_by_me' => 'متعلق به من است',
+    'search_date_options' => 'گزینه های تاریخ',
+    'search_updated_before' => 'قبلا به روز شده',
+    'search_updated_after' => 'پس از به روز رسانی',
+    'search_created_before' => 'قبلا ایجاد شده است',
+    'search_created_after' => 'ایجاد شده پس از',
+    'search_set_date' => 'تنظیم تاریخ',
+    'search_update' => 'جستجو را به روز کنید',
 
     // Shelves
-    'shelf' => 'Shelf',
-    'shelves' => 'Shelves',
-    'x_shelves' => ':count Shelf|:count Shelves',
-    'shelves_long' => 'Bookshelves',
-    'shelves_empty' => 'No shelves have been created',
+    'shelf' => 'تاقچه',
+    'shelves' => 'قفسه ها',
+    'x_shelves' => ':count تاقچه|:count تاقچه',
+    'shelves_long' => 'قفسه کتاب',
+    'shelves_empty' => 'هیچ قفسه ای ایجاد نشده است',
     'shelves_create' => 'Create New Shelf',
-    'shelves_popular' => 'Popular Shelves',
-    'shelves_new' => 'New Shelves',
-    'shelves_new_action' => 'New Shelf',
-    'shelves_popular_empty' => 'The most popular shelves will appear here.',
-    'shelves_new_empty' => 'The most recently created shelves will appear here.',
-    'shelves_save' => 'Save Shelf',
-    'shelves_books' => 'Books on this shelf',
-    'shelves_add_books' => 'Add books to this shelf',
-    'shelves_drag_books' => 'Drag books here to add them to this shelf',
-    'shelves_empty_contents' => 'This shelf has no books assigned to it',
-    'shelves_edit_and_assign' => 'Edit shelf to assign books',
-    'shelves_edit_named' => 'Edit Bookshelf :name',
-    'shelves_edit' => 'Edit Bookshelf',
-    'shelves_delete' => 'Delete Bookshelf',
-    'shelves_delete_named' => 'Delete Bookshelf :name',
-    'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
-    'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
-    'shelves_permissions' => 'Bookshelf Permissions',
-    'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
-    'shelves_permissions_active' => 'Bookshelf Permissions Active',
-    'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
-    'shelves_copy_permissions' => 'Copy Permissions',
-    'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
-    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
+    'shelves_popular' => 'قفسه های محبوب',
+    'shelves_new' => 'قفسه های جدید',
+    'shelves_new_action' => 'قفسه جدید',
+    'shelves_popular_empty' => 'محبوب ترین قفسه ها در اینجا ظاهر می شوند.',
+    'shelves_new_empty' => 'جدیدترین قفسه های ایجاد شده در اینجا ظاهر می شوند.',
+    'shelves_save' => 'ذخیره قفسه',
+    'shelves_books' => 'کتاب های موجود در این قفسه',
+    'shelves_add_books' => 'کتاب ها را به این قفسه اضافه کنید',
+    'shelves_drag_books' => 'کتاب‌ها را به اینجا بکشید تا به این قفسه اضافه شوند',
+    'shelves_empty_contents' => 'این قفسه هیچ کتابی به آن اختصاص داده نشده است',
+    'shelves_edit_and_assign' => 'برای اختصاص کتاب‌ها، قفسه را ویرایش کنید',
+    'shelves_edit_named' => 'ویرایش قفسه کتاب :name',
+    'shelves_edit' => 'ویرایش قفسه کتاب',
+    'shelves_delete' => 'حذف قفسه کتاب',
+    'shelves_delete_named' => 'حذف قفسه کتاب :name',
+    'shelves_delete_explain' => "با این کار قفسه کتاب با نام ':name' حذف می شود. کتاب های موجود حذف نمی شوند.",
+    'shelves_delete_confirmation' => 'آیا مطمئنید که می خواهید این قفسه کتاب را حذف کنید؟',
+    'shelves_permissions' => 'مجوزهای قفسه کتاب',
+    'shelves_permissions_updated' => 'مجوزهای قفسه کتاب به روز شد',
+    'shelves_permissions_active' => 'مجوزهای قفسه کتاب فعال است',
+    'shelves_permissions_cascade_warning' => 'مجوزهای موجود در قفسه‌های کتاب به طور خودکار به کتاب‌های حاوی آبشار نمی‌شوند. این به این دلیل است که یک کتاب می تواند در چندین قفسه وجود داشته باشد. با این حال، مجوزها را می‌توان با استفاده از گزینه زیر در کتاب‌های کودک کپی کرد.',
+    'shelves_copy_permissions_to_books' => 'کپی مجوزها در کتابها',
+    'shelves_copy_permissions' => 'مجوزهای کپی',
+    'shelves_copy_permissions_explain' => 'با این کار تنظیمات مجوز فعلی این قفسه کتاب برای همه کتاب‌های موجود در آن اعمال می‌شود. قبل از فعال کردن، مطمئن شوید که هر گونه تغییر در مجوزهای این قفسه کتاب ذخیره شده است.',
+    'shelves_copy_permission_success' => 'مجوزهای قفسه کتاب در :count books کپی شد',
 
     // Books
-    'book' => 'Book',
-    'books' => 'Books',
-    'x_books' => ':count Book|:count Books',
-    'books_empty' => 'No books have been created',
-    'books_popular' => 'Popular Books',
-    'books_recent' => 'Recent Books',
-    'books_new' => 'New Books',
-    'books_new_action' => 'New Book',
-    'books_popular_empty' => 'The most popular books will appear here.',
-    'books_new_empty' => 'The most recently created books will appear here.',
-    'books_create' => 'Create New Book',
-    'books_delete' => 'Delete Book',
-    'books_delete_named' => 'Delete Book :bookName',
-    'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.',
-    'books_delete_confirmation' => 'Are you sure you want to delete this book?',
-    'books_edit' => 'Edit Book',
-    'books_edit_named' => 'Edit Book :bookName',
-    'books_form_book_name' => 'Book Name',
-    'books_save' => 'Save Book',
-    'books_permissions' => 'Book Permissions',
-    'books_permissions_updated' => 'Book Permissions Updated',
-    'books_empty_contents' => 'No pages or chapters have been created for this book.',
-    'books_empty_create_page' => 'Create a new page',
-    'books_empty_sort_current_book' => 'Sort the current book',
-    'books_empty_add_chapter' => 'Add a chapter',
-    'books_permissions_active' => 'Book Permissions Active',
-    'books_search_this' => 'Search this book',
-    'books_navigation' => 'Book Navigation',
-    'books_sort' => 'Sort Book Contents',
-    'books_sort_named' => 'Sort Book :bookName',
-    'books_sort_name' => 'Sort by Name',
-    'books_sort_created' => 'Sort by Created Date',
-    'books_sort_updated' => 'Sort by Updated Date',
-    'books_sort_chapters_first' => 'Chapters First',
-    'books_sort_chapters_last' => 'Chapters Last',
-    'books_sort_show_other' => 'Show Other Books',
-    'books_sort_save' => 'Save New Order',
+    'book' => 'کتاب',
+    'books' => 'کتابها',
+    'x_books' => ':count کتاب|:count کتاب',
+    'books_empty' => 'هیچ کتابی ایجاد نشده است',
+    'books_popular' => 'کتاب های محبوب',
+    'books_recent' => 'کتاب های اخیر',
+    'books_new' => 'کتاب های جدید',
+    'books_new_action' => 'کتاب جدید',
+    'books_popular_empty' => 'محبوب ترین کتاب ها در اینجا ظاهر می شوند.',
+    'books_new_empty' => 'جدیدترین کتاب‌های ایجاد شده در اینجا ظاهر می‌شوند.',
+    'books_create' => 'ایجاد کتاب جدید',
+    'books_delete' => 'حذف کتاب',
+    'books_delete_named' => 'حذف کتاب:bookName',
+    'books_delete_explain' => 'با این کار کتابی با نام \':bookName\' حذف می شود. تمام صفحات و فصل ها حذف خواهند شد.',
+    'books_delete_confirmation' => 'آیا مطمئن هستید که می خواهید این کتاب را حذف کنید؟',
+    'books_edit' => 'ویرایش کتاب',
+    'books_edit_named' => 'ویرایش کتاب:bookName',
+    'books_form_book_name' => 'نام کتاب',
+    'books_save' => 'ذخیره کتاب',
+    'books_permissions' => 'مجوزهای کتاب',
+    'books_permissions_updated' => 'مجوزهای کتاب به روز شد',
+    'books_empty_contents' => 'هیچ صفحه یا فصلی برای این کتاب ایجاد نشده است.',
+    'books_empty_create_page' => 'یک صفحه جدید ایجاد کنید',
+    'books_empty_sort_current_book' => 'کتاب فعلی را مرتب کنید',
+    'books_empty_add_chapter' => 'یک فصل اضافه کنید',
+    'books_permissions_active' => 'مجوزهای کتاب فعال است',
+    'books_search_this' => 'این کتاب را جستجو کنید',
+    'books_navigation' => 'ناوبری کتاب',
+    'books_sort' => 'مرتب سازی مطالب کتاب',
+    'books_sort_named' => 'مرتب سازی کتاب:bookName',
+    'books_sort_name' => 'مرتب سازی بر اساس نام',
+    'books_sort_created' => 'مرتب سازی بر اساس تاریخ ایجاد',
+    'books_sort_updated' => 'مرتب سازی بر اساس تاریخ به روز رسانی',
+    'books_sort_chapters_first' => 'فصل اول',
+    'books_sort_chapters_last' => 'فصل آخر',
+    'books_sort_show_other' => 'نمایش کتاب های دیگر',
+    'books_sort_save' => 'ذخیره سفارش جدید',
+    'books_copy' => 'کپی کتاب',
+    'books_copy_success' => 'کتاب با موفقیت کپی شد',
 
     // Chapters
-    'chapter' => 'Chapter',
-    'chapters' => 'Chapters',
-    'x_chapters' => ':count Chapter|:count Chapters',
-    'chapters_popular' => 'Popular Chapters',
-    'chapters_new' => 'New Chapter',
-    'chapters_create' => 'Create New Chapter',
-    'chapters_delete' => 'Delete Chapter',
-    'chapters_delete_named' => 'Delete Chapter :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
-    'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?',
-    'chapters_edit' => 'Edit Chapter',
-    'chapters_edit_named' => 'Edit Chapter :chapterName',
-    'chapters_save' => 'Save Chapter',
-    'chapters_move' => 'Move Chapter',
-    'chapters_move_named' => 'Move Chapter :chapterName',
-    'chapter_move_success' => 'Chapter moved to :bookName',
-    'chapters_permissions' => 'Chapter Permissions',
-    'chapters_empty' => 'No pages are currently in this chapter.',
-    'chapters_permissions_active' => 'Chapter Permissions Active',
-    'chapters_permissions_success' => 'Chapter Permissions Updated',
-    'chapters_search_this' => 'Search this chapter',
+    'chapter' => 'فصل',
+    'chapters' => 'فصل',
+    'x_chapters' => ':count فصل|:count فصل',
+    'chapters_popular' => 'فصل های محبوب',
+    'chapters_new' => 'فصل جدید',
+    'chapters_create' => 'ایجاد فصل جدید',
+    'chapters_delete' => 'حذف فصل',
+    'chapters_delete_named' => 'حذف فصل :chapterName',
+    'chapters_delete_explain' => 'با این کار فصلی با نام \':chapterName\' حذف می شود. تمامی صفحاتی که در این فصل وجود دارند نیز حذف خواهند شد.',
+    'chapters_delete_confirm' => 'آیا مطمئن هستید که می خواهید این فصل را حذف کنید؟',
+    'chapters_edit' => 'ویرایش فصل',
+    'chapters_edit_named' => 'ویرایش فصل :chapterName',
+    'chapters_save' => 'ذخیره فصل',
+    'chapters_move' => 'انتقال فصل',
+    'chapters_move_named' => 'انتقال فصل :chapterName',
+    'chapter_move_success' => 'فصل به :bookName منتقل شد',
+    'chapters_copy' => 'کپی کردن فصل',
+    'chapters_copy_success' => 'فصل با موفقیت کپی شد',
+    'chapters_permissions' => 'مجوزهای فصل',
+    'chapters_empty' => 'در حال حاضر هیچ صفحه ای در این فصل وجود ندارد.',
+    'chapters_permissions_active' => 'مجوزهای فصل فعال است',
+    'chapters_permissions_success' => 'مجوزهای فصل به روز شد',
+    'chapters_search_this' => 'این فصل را جستجو کنید',
 
     // Pages
-    'page' => 'Page',
-    'pages' => 'Pages',
-    'x_pages' => ':count Page|:count Pages',
-    'pages_popular' => 'Popular Pages',
-    'pages_new' => 'New Page',
-    'pages_attachments' => 'Attachments',
-    'pages_navigation' => 'Page Navigation',
-    'pages_delete' => 'Delete Page',
-    'pages_delete_named' => 'Delete Page :pageName',
-    'pages_delete_draft_named' => 'Delete Draft Page :pageName',
-    'pages_delete_draft' => 'Delete Draft Page',
-    'pages_delete_success' => 'Page deleted',
-    'pages_delete_draft_success' => 'Draft page deleted',
-    'pages_delete_confirm' => 'Are you sure you want to delete this page?',
-    'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?',
-    'pages_editing_named' => 'Editing Page :pageName',
-    'pages_edit_draft_options' => 'Draft Options',
-    'pages_edit_save_draft' => 'Save Draft',
-    'pages_edit_draft' => 'Edit Page Draft',
-    'pages_editing_draft' => 'Editing Draft',
-    'pages_editing_page' => 'Editing Page',
-    'pages_edit_draft_save_at' => 'Draft saved at ',
-    'pages_edit_delete_draft' => 'Delete Draft',
-    'pages_edit_discard_draft' => 'Discard Draft',
-    'pages_edit_set_changelog' => 'Set Changelog',
-    'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
-    'pages_edit_enter_changelog' => 'Enter Changelog',
-    'pages_save' => 'Save Page',
-    'pages_title' => 'Page Title',
-    'pages_name' => 'Page Name',
-    'pages_md_editor' => 'Editor',
-    'pages_md_preview' => 'Preview',
-    'pages_md_insert_image' => 'Insert Image',
-    'pages_md_insert_link' => 'Insert Entity Link',
-    'pages_md_insert_drawing' => 'Insert Drawing',
-    'pages_not_in_chapter' => 'Page is not in a chapter',
-    'pages_move' => 'Move Page',
-    'pages_move_success' => 'Page moved to ":parentName"',
-    'pages_copy' => 'Copy Page',
-    'pages_copy_desination' => 'Copy Destination',
-    'pages_copy_success' => 'Page successfully copied',
-    'pages_permissions' => 'Page Permissions',
-    'pages_permissions_success' => 'Page permissions updated',
-    'pages_revision' => 'Revision',
-    'pages_revisions' => 'Page Revisions',
-    'pages_revisions_named' => 'Page Revisions for :pageName',
-    'pages_revision_named' => 'Page Revision for :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
-    'pages_revisions_created_by' => 'Created By',
-    'pages_revisions_date' => 'Revision Date',
+    'page' => 'صفحه',
+    'pages' => 'صفحات',
+    'x_pages' => ':count صفحه|:count صفحه',
+    'pages_popular' => 'صفحات محبوب',
+    'pages_new' => 'صفحه جدید',
+    'pages_attachments' => 'پیوست‌ها',
+    'pages_navigation' => 'پیمایش صفحه',
+    'pages_delete' => 'حذف صفحه',
+    'pages_delete_named' => 'حذف صفحه:pageName',
+    'pages_delete_draft_named' => 'حذف پیش نویس صفحه:pageName',
+    'pages_delete_draft' => 'حذف صفحه پیش نویس',
+    'pages_delete_success' => 'صفحه حذف شد',
+    'pages_delete_draft_success' => 'صفحه پیش نویس حذف شد',
+    'pages_delete_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه را حذف کنید؟',
+    'pages_delete_draft_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه پیش نویس را حذف کنید؟',
+    'pages_editing_named' => 'ویرایش صفحه :pageName',
+    'pages_edit_draft_options' => 'گزینه های پیش نویس',
+    'pages_edit_save_draft' => 'ذخیره پیش نویس',
+    'pages_edit_draft' => 'ویرایش پیش نویس صفحه',
+    'pages_editing_draft' => 'در حال ویرایش پیش نویس',
+    'pages_editing_page' => 'در حال ویرایش صفحه',
+    'pages_edit_draft_save_at' => 'پیش نویس ذخیره شده در',
+    'pages_edit_delete_draft' => 'حذف پیش نویس',
+    'pages_edit_discard_draft' => 'دور انداختن پیش نویس',
+    'pages_edit_set_changelog' => 'تنظیم تغییرات',
+    'pages_edit_enter_changelog_desc' => 'توضیح مختصری از تغییراتی که ایجاد کرده اید وارد کنید',
+    'pages_edit_enter_changelog' => 'وارد کردن تغییرات',
+    'pages_save' => 'ذخیره صفحه',
+    'pages_title' => 'عنوان صفحه',
+    'pages_name' => 'نام صفحه',
+    'pages_md_editor' => 'ویرایشگر',
+    'pages_md_preview' => 'پيش نمايش',
+    'pages_md_insert_image' => 'درج تصویر',
+    'pages_md_insert_link' => 'پیوند نهاد را درج کنید',
+    'pages_md_insert_drawing' => 'درج طرح',
+    'pages_not_in_chapter' => 'صفحه در یک فصل نیست',
+    'pages_move' => 'انتقال صفحه',
+    'pages_move_success' => 'صفحه به ":parentName" منتقل شد',
+    'pages_copy' => 'صفحه را کپی کنید',
+    'pages_copy_desination' => 'مقصد را کپی کنید',
+    'pages_copy_success' => 'صفحه با موفقیت کپی شد',
+    'pages_permissions' => 'مجوزهای صفحه',
+    'pages_permissions_success' => 'مجوزهای صفحه به روز شد',
+    'pages_revision' => 'تجدید نظر',
+    'pages_revisions' => 'ویرایش های صفحه',
+    'pages_revisions_named' => 'بازبینی صفحه برای :pageName',
+    'pages_revision_named' => 'ویرایش صفحه برای :pageName',
+    'pages_revision_restored_from' => 'بازیابی شده از #:id; :summary',
+    'pages_revisions_created_by' => 'ایجاد شده توسط',
+    'pages_revisions_date' => 'تاریخ تجدید نظر',
     'pages_revisions_number' => '#',
-    'pages_revisions_numbered' => 'Revision #:id',
-    'pages_revisions_numbered_changes' => 'Revision #:id Changes',
-    'pages_revisions_changelog' => 'Changelog',
-    'pages_revisions_changes' => 'Changes',
-    'pages_revisions_current' => 'Current Version',
-    'pages_revisions_preview' => 'Preview',
-    'pages_revisions_restore' => 'Restore',
-    'pages_revisions_none' => 'This page has no revisions',
-    'pages_copy_link' => 'Copy Link',
-    'pages_edit_content_link' => 'Edit Content',
-    'pages_permissions_active' => 'Page Permissions Active',
-    'pages_initial_revision' => 'Initial publish',
-    'pages_initial_name' => 'New Page',
-    'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.',
-    'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.',
+    'pages_revisions_numbered' => 'تجدید نظر #:id',
+    'pages_revisions_numbered_changes' => 'بازبینی #:id تغییرات',
+    'pages_revisions_changelog' => 'لیست تغییرات',
+    'pages_revisions_changes' => 'تغییرات',
+    'pages_revisions_current' => 'نسخه‌ی جاری',
+    'pages_revisions_preview' => 'پيش نمايش',
+    'pages_revisions_restore' => 'بازگرداندن',
+    'pages_revisions_none' => 'این صفحه هیچ ویرایشی ندارد',
+    'pages_copy_link' => 'کپی لینک',
+    'pages_edit_content_link' => 'ویرایش محتوا',
+    'pages_permissions_active' => 'مجوزهای صفحه فعال است',
+    'pages_initial_revision' => 'انتشار اولیه',
+    'pages_initial_name' => 'برگهٔ تازه',
+    'pages_editing_draft_notification' => 'شما در حال ویرایش پیش نویسی هستید که آخرین بار در :timeDiff ذخیره شده است.',
+    'pages_draft_edited_notification' => 'این صفحه از همان زمان به روز شده است. توصیه می شود از این پیش نویس صرف نظر کنید.',
+    'pages_draft_page_changed_since_creation' => 'این صفحه از زمان ایجاد این پیش نویس به روز شده است. توصیه می‌شود که این پیش‌نویس را کنار بگذارید یا مراقب باشید که تغییرات صفحه را بازنویسی نکنید.',
     'pages_draft_edit_active' => [
-        'start_a' => ':count users have started editing this page',
-        'start_b' => ':userName has started editing this page',
-        'time_a' => 'since the page was last updated',
-        'time_b' => 'in the last :minCount minutes',
-        'message' => ':start :time. Take care not to overwrite each other\'s updates!',
+        'start_a' => ':count کاربران شروع به ویرایش این صفحه کرده اند',
+        'start_b' => ':userName ویرایش این صفحه را شروع کرده است',
+        'time_a' => 'از آخرین به روز رسانی صفحه',
+        'time_b' => 'در آخرین دقیقه :minCount',
+        'message' => ':start :time. مراقب باشید به روز رسانی های یکدیگر را بازنویسی نکنید!',
     ],
-    'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content',
-    'pages_specific' => 'Specific Page',
-    'pages_is_template' => 'Page Template',
+    'pages_draft_discarded' => 'پیش نویس حذف شد، ویرایشگر با محتوای صفحه فعلی به روز شده است',
+    'pages_specific' => 'صفحه خاص',
+    'pages_is_template' => 'الگوی صفحه',
 
     // Editor Sidebar
-    'page_tags' => 'Page Tags',
-    'chapter_tags' => 'Chapter Tags',
-    'book_tags' => 'Book Tags',
-    'shelf_tags' => 'Shelf Tags',
-    'tag' => 'Tag',
-    'tags' =>  'Tags',
-    'tag_name' =>  'Tag Name',
-    'tag_value' => 'Tag Value (Optional)',
-    'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
-    'tags_add' => 'Add another tag',
-    'tags_remove' => 'Remove this tag',
-    'attachments' => 'Attachments',
-    'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.',
-    'attachments_explain_instant_save' => 'Changes here are saved instantly.',
-    'attachments_items' => 'Attached Items',
-    'attachments_upload' => 'Upload File',
-    'attachments_link' => 'Attach Link',
-    'attachments_set_link' => 'Set Link',
-    'attachments_delete' => 'Are you sure you want to delete this attachment?',
-    'attachments_dropzone' => 'Drop files or click here to attach a file',
-    'attachments_no_files' => 'No files have been uploaded',
-    'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.',
-    'attachments_link_name' => 'Link Name',
-    'attachment_link' => 'Attachment link',
-    'attachments_link_url' => 'Link to file',
-    'attachments_link_url_hint' => 'Url of site or file',
-    'attach' => 'Attach',
-    'attachments_insert_link' => 'Add Attachment Link to Page',
-    'attachments_edit_file' => 'Edit File',
-    'attachments_edit_file_name' => 'File Name',
-    'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite',
-    'attachments_order_updated' => 'Attachment order updated',
-    'attachments_updated_success' => 'Attachment details updated',
-    'attachments_deleted' => 'Attachment deleted',
-    'attachments_file_uploaded' => 'File successfully uploaded',
-    'attachments_file_updated' => 'File successfully updated',
-    'attachments_link_attached' => 'Link successfully attached to page',
-    'templates' => 'Templates',
-    'templates_set_as_template' => 'Page is a template',
-    'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
-    'templates_replace_content' => 'Replace page content',
-    'templates_append_content' => 'Append to page content',
-    'templates_prepend_content' => 'Prepend to page content',
+    'page_tags' => 'تگ های صفحه',
+    'chapter_tags' => 'برچسب های فصل',
+    'book_tags' => 'برچسب های کتاب',
+    'shelf_tags' => 'برچسب های قفسه',
+    'tag' => 'برچسب',
+    'tags' =>  'برچسب ها',
+    'tag_name' =>  'نام برچسب',
+    'tag_value' => 'مقدار برچسب (اختیاری)',
+    'tags_explain' => "برای دسته بندی بهتر مطالب خود چند تگ اضافه کنید.\n می توانید برای سازماندهی عمیق تر، یک مقدار به یک برچسب اختصاص دهید.",
+    'tags_add' => 'یک برچسب دیگر اضافه کنید',
+    'tags_remove' => 'این تگ را حذف کنید',
+    'tags_usages' => 'مجموع استفاده از برچسب',
+    'tags_assigned_pages' => 'به صفحات اختصاص داده شده است',
+    'tags_assigned_chapters' => 'اختصاص به فصل',
+    'tags_assigned_books' => 'به کتاب ها اختصاص داده شده است',
+    'tags_assigned_shelves' => 'به قفسه ها اختصاص داده شده است',
+    'tags_x_unique_values' => ':count مقادیر منحصر به فرد',
+    'tags_all_values' => 'همه ارزش ها',
+    'tags_view_tags' => 'مشاهده برچسب ها',
+    'tags_view_existing_tags' => 'مشاهده تگ های موجود',
+    'tags_list_empty_hint' => 'برچسب ها را می توان از طریق نوار کناری ویرایشگر صفحه یا هنگام ویرایش جزئیات یک کتاب، فصل یا قفسه اختصاص داد.',
+    'attachments' => 'پیوست ها',
+    'attachments_explain' => 'چند فایل را آپلود کنید یا چند پیوند را برای نمایش در صفحه خود ضمیمه کنید. اینها در نوار کناری صفحه قابل مشاهده هستند.',
+    'attachments_explain_instant_save' => 'تغییرات در اینجا فورا ذخیره می شوند.',
+    'attachments_items' => 'موارد پیوست شده',
+    'attachments_upload' => 'آپلود فایل',
+    'attachments_link' => 'پیوند را ضمیمه کنید',
+    'attachments_set_link' => 'پیوند را تنظیم کنید',
+    'attachments_delete' => 'آیا مطمئن هستید که می خواهید این پیوست را حذف کنید؟',
+    'attachments_dropzone' => 'فایل ها را رها کنید یا برای پیوست کردن یک فایل اینجا را کلیک کنید',
+    'attachments_no_files' => 'هیچ فایلی آپلود نشده است',
+    'attachments_explain_link' => 'اگر ترجیح می دهید فایلی را آپلود نکنید، می توانید پیوندی را پیوست کنید. این می تواند پیوندی به صفحه دیگر یا پیوندی به فایلی در فضای ابری باشد.',
+    'attachments_link_name' => 'نام پیوند',
+    'attachment_link' => 'لینک پیوست',
+    'attachments_link_url' => 'پیوند به فایل',
+    'attachments_link_url_hint' => 'آدرس سایت یا فایل',
+    'attach' => 'ضمیمه کنید',
+    'attachments_insert_link' => 'پیوند پیوست را به صفحه اضافه کنید',
+    'attachments_edit_file' => 'ویرایش فایل',
+    'attachments_edit_file_name' => 'نام فایل',
+    'attachments_edit_drop_upload' => 'فایل ها را رها کنید یا برای آپلود و بازنویسی اینجا کلیک کنید',
+    'attachments_order_updated' => 'سفارش پیوست به روز شد',
+    'attachments_updated_success' => 'جزئیات پیوست به روز شد',
+    'attachments_deleted' => 'پیوست حذف شد',
+    'attachments_file_uploaded' => 'فایل با موفقیت آپلود شد',
+    'attachments_file_updated' => 'فایل با موفقیت به روز شد',
+    'attachments_link_attached' => 'پیوند با موفقیت به صفحه پیوست شد',
+    'templates' => 'قالب ها',
+    'templates_set_as_template' => 'صفحه یک الگو است',
+    'templates_explain_set_as_template' => 'می توانید این صفحه را به عنوان یک الگو تنظیم کنید تا از محتویات آن هنگام ایجاد صفحات دیگر استفاده شود. سایر کاربران در صورت داشتن مجوز مشاهده برای این صفحه می توانند از این الگو استفاده کنند.',
+    'templates_replace_content' => 'محتوای صفحه را جایگزین کنید',
+    'templates_append_content' => 'به محتوای صفحه اضافه کنید',
+    'templates_prepend_content' => 'به محتوای صفحه اضافه کنید',
 
     // Profile View
-    'profile_user_for_x' => 'User for :time',
-    'profile_created_content' => 'Created Content',
-    'profile_not_created_pages' => ':userName has not created any pages',
-    'profile_not_created_chapters' => ':userName has not created any chapters',
-    'profile_not_created_books' => ':userName has not created any books',
-    'profile_not_created_shelves' => ':userName has not created any shelves',
+    'profile_user_for_x' => 'کاربر برای :time',
+    'profile_created_content' => 'محتوا ایجاد کرد',
+    'profile_not_created_pages' => ':userName هیچ صفحه ای ایجاد نکرده است',
+    'profile_not_created_chapters' => ':userName هیچ فصلی ایجاد نکرده است',
+    'profile_not_created_books' => ':userName هیچ کتابی ایجاد نکرده است',
+    'profile_not_created_shelves' => ':userName هیچ قفسه ای ایجاد نکرده است',
 
     // Comments
-    'comment' => 'Comment',
-    'comments' => 'Comments',
-    'comment_add' => 'Add Comment',
-    'comment_placeholder' => 'Leave a comment here',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
-    'comment_save' => 'Save Comment',
-    'comment_saving' => 'Saving comment...',
-    'comment_deleting' => 'Deleting comment...',
-    'comment_new' => 'New Comment',
-    'comment_created' => 'commented :createDiff',
-    'comment_updated' => 'Updated :updateDiff by :username',
-    'comment_deleted_success' => 'Comment deleted',
-    'comment_created_success' => 'Comment added',
-    'comment_updated_success' => 'Comment updated',
-    'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
-    'comment_in_reply_to' => 'In reply to :commentId',
+    'comment' => 'اظهار نظر',
+    'comments' => 'نظرات',
+    'comment_add' => 'افزودن توضیح',
+    'comment_placeholder' => 'اینجا نظر بدهید',
+    'comment_count' => '{0} بدون نظر|{1} 1 نظر|[2,*] :count نظرات',
+    'comment_save' => 'ذخیره نظر',
+    'comment_saving' => 'در حال ذخیره نظر...',
+    'comment_deleting' => 'در حال حذف نظر...',
+    'comment_new' => 'نظر جدید',
+    'comment_created' => ':createDiff نظر داد',
+    'comment_updated' => 'به روز رسانی :updateDiff توسط :username',
+    'comment_deleted_success' => 'نظر حذف شد',
+    'comment_created_success' => 'نظر اضافه شد',
+    'comment_updated_success' => 'نظر به روز شد',
+    'comment_delete_confirm' => 'آیا مطمئن هستید که می خواهید این نظر را حذف کنید؟',
+    'comment_in_reply_to' => 'در پاسخ به :commentId',
 
     // Revision
-    'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
-    'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
-    'revision_delete_success' => 'Revision deleted',
-    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
+    'revision_delete_confirm' => 'آیا مطمئن هستید که می خواهید این ویرایش را حذف کنید؟',
+    'revision_restore_confirm' => 'آیا مطمئن هستید که می خواهید این ویرایش را بازیابی کنید؟ محتوای صفحه فعلی جایگزین خواهد شد.',
+    'revision_delete_success' => 'ویرایش حذف شد',
+    'revision_cannot_delete_latest' => 'نمی توان آخرین نسخه را حذف کرد.',
+
+    // Copy view
+    'copy_consider' => 'لطفاً هنگام کپی کردن مطالب به موارد زیر توجه کنید.',
+    'copy_consider_permissions' => 'تنظیمات مجوز سفارشی کپی نخواهد شد.',
+    'copy_consider_owner' => 'شما مالک تمام محتوای کپی شده خواهید شد.',
+    'copy_consider_images' => 'فایل های تصویر صفحه تکراری نخواهند شد و تصاویر اصلی ارتباط خود را با صفحه ای که در ابتدا در آن آپلود شده اند حفظ می کنند.',
+    'copy_consider_attachments' => 'پیوست های صفحه کپی نمی شود.',
+    'copy_consider_access' => 'تغییر مکان، مالک یا مجوزها ممکن است منجر به دسترسی به این محتوا برای افرادی شود که قبلاً به آنها دسترسی نداشتند.',
 ];
index 79024e482ed69efa633116592f9b7c83a0bcc93a..9f3b14b9a7c34afd083145c8b7a2ae12f7d69f3b 100644 (file)
 return [
 
     // Permissions
-    'permission' => 'You do not have permission to access the requested page.',
-    'permissionJson' => 'You do not have permission to perform the requested action.',
+    'permission' => 'شما مجوز مشاهده صفحه درخواست شده را ندارید.',
+    'permissionJson' => 'شما مجاز به انجام این عمل نیستید.',
 
     // Auth
-    'error_user_exists_different_creds' => 'A user with the email :email already exists but with different credentials.',
-    'email_already_confirmed' => 'Email has already been confirmed, Try logging in.',
-    'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.',
-    'email_confirmation_expired' => 'The confirmation token has expired, A new confirmation email has been sent.',
-    'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed',
-    'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind',
-    'ldap_fail_authed' => 'LDAP access failed using given dn & password details',
-    'ldap_extension_not_installed' => 'LDAP PHP extension not installed',
-    'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed',
-    'saml_already_logged_in' => 'Already logged in',
-    'saml_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
-    'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
-    'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
-    'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
-    'social_no_action_defined' => 'No action defined',
-    'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
-    'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.',
-    'social_account_email_in_use' => 'The email :email is already in use. If you already have an account you can connect your :socialAccount account from your profile settings.',
-    'social_account_existing' => 'This :socialAccount is already attached to your profile.',
-    'social_account_already_used_existing' => 'This :socialAccount account is already used by another user.',
-    'social_account_not_used' => 'This :socialAccount account is not linked to any users. Please attach it in your profile settings. ',
-    'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.',
-    'social_driver_not_found' => 'Social driver not found',
-    'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.',
-    'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
+    'error_user_exists_different_creds' => 'کاربری با ایمیل :email از قبل وجود دارد اما دارای اطلاعات متفاوتی می باشد.',
+    'email_already_confirmed' => 'ایمیل قبلا تایید شده است، وارد سیستم شوید.',
+    'email_confirmation_invalid' => 'این کلمه عبور معتبر نمی باشد و یا قبلا استفاده شده است، لطفا دوباره ثبت نام نمایید.',
+    'email_confirmation_expired' => 'کلمه عبور منقضی شده است، یک ایمیل تایید جدید ارسال شد.',
+    'email_confirmation_awaiting' => 'آدرس ایمیل حساب مورد استفاده باید تایید شود',
+    'ldap_fail_anonymous' => 'دسترسی LDAP با استفاده از صحافی ناشناس انجام نشد',
+    'ldap_fail_authed' => 'دسترسی به LDAP با استفاده از جزئیات داده شده و رمز عبور انجام نشد',
+    'ldap_extension_not_installed' => 'افزونه PHP LDAP نصب نشده است',
+    'ldap_cannot_connect' => 'اتصال به سرور LDAP امکان پذیر نیست، اتصال اولیه برقرار نشد',
+    'saml_already_logged_in' => 'قبلا وارد سیستم شده اید',
+    'saml_user_not_registered' => 'کاربر :name ثبت نشده است و ثبت نام خودکار غیرفعال است',
+    'saml_no_email_address' => 'آدرس داده ای برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد',
+    'saml_invalid_response_id' => 'درخواست از سیستم احراز هویت خارجی توسط فرایندی که توسط این نرم افزار آغاز شده است شناخته نمی شود. بازگشت به سیستم پس از ورود به سیستم می تواند باعث این مسئله شود.',
+    'saml_fail_authed' => 'ورود به سیستم :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد',
+    'oidc_already_logged_in' => 'قبلا وارد شده اید',
+    'oidc_user_not_registered' => 'کاربر :name ثبت نشده و ثبت نام خودکار غیرفعال است',
+    'oidc_no_email_address' => 'آدرس ایمیلی برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد',
+    'oidc_fail_authed' => 'ورود به سیستم با استفاده از :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد',
+    'social_no_action_defined' => 'عملی تعریف نشده است',
+    'social_login_bad_response' => "خطای دریافت شده در هنگام ورود به سیستم:\n:error",
+    'social_account_in_use' => 'این حساب :socialAccount از قبل در حال استفاده است، سعی کنید از طریق گزینه :socialAccount وارد سیستم شوید.',
+    'social_account_email_in_use' => 'ایمیل :email از قبل در حال استفاده است. اگر از قبل حساب کاربری دارید می توانید از تنظیمات نمایه خود :socialAccount خود را وصل کنید.',
+    'social_account_existing' => 'این :socialAccount از قبل به نمایه شما پیوست شده است.',
+    'social_account_already_used_existing' => 'این حساب :socialAccount قبلا توسط کاربر دیگری استفاده شده است.',
+    'social_account_not_used' => 'این حساب :socialAccount به هیچ کاربری پیوند ندارد. لطفا آن را در تنظیمات نمایه خود ضمیمه کنید. ',
+    'social_account_register_instructions' => 'اگر هنوز حساب کاربری ندارید ، می توانید با استفاده از گزینه :socialAccount حساب خود را ثبت کنید.',
+    'social_driver_not_found' => 'درایور شبکه اجتماعی یافت نشد',
+    'social_driver_not_configured' => 'تنظیمات شبکه اجتماعی :socialAccount به درستی پیکربندی نشده است.',
+    'invite_token_expired' => 'این پیوند دعوت منقضی شده است. در عوض می توانید سعی کنید رمز عبور حساب خود را بازنشانی کنید.',
 
     // System
-    'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',
-    'cannot_get_image_from_url' => 'Cannot get image from :url',
-    'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.',
-    'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.',
-    'uploaded'  => 'The server does not allow uploads of this size. Please try a smaller file size.',
-    'image_upload_error' => 'An error occurred uploading the image',
-    'image_upload_type_error' => 'The image type being uploaded is invalid',
-    'file_upload_timeout' => 'The file upload has timed out.',
+    'path_not_writable' => 'مسیر فایل :filePath را نمی توان در آن آپلود کرد. مطمئن شوید که روی سرور قابل نوشتن است.',
+    'cannot_get_image_from_url' => 'نمی توان تصویر را از :url دریافت کرد',
+    'cannot_create_thumbs' => 'سرور نمی تواند تصاویر کوچک ایجاد کند. لطفاً بررسی کنید که پسوند GD PHP را نصب کرده اید.',
+    'server_upload_limit' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.',
+    'uploaded'  => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.',
+    'image_upload_error' => 'هنگام آپلود تصویر خطایی روی داد',
+    'image_upload_type_error' => 'نوع تصویر در حال آپلود نامعتبر است',
+    'file_upload_timeout' => 'زمان بارگذاری فایل به پایان رسیده است.',
 
     // Attachments
-    'attachment_not_found' => 'Attachment not found',
+    'attachment_not_found' => 'پیوست یافت نشد',
 
     // Pages
-    'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page',
-    'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
+    'page_draft_autosave_fail' => 'پیش نویس ذخیره نشد. قبل از ذخیره این صفحه مطمئن شوید که به اینترنت متصل هستید',
+    'page_custom_home_deletion' => 'وقتی صفحه ای به عنوان صفحه اصلی تنظیم شده است، نمی توان آن را حذف کرد',
 
     // Entities
-    'entity_not_found' => 'Entity not found',
-    'bookshelf_not_found' => 'Bookshelf not found',
-    'book_not_found' => 'Book not found',
-    'page_not_found' => 'Page not found',
-    'chapter_not_found' => 'Chapter not found',
-    'selected_book_not_found' => 'The selected book was not found',
-    'selected_book_chapter_not_found' => 'The selected Book or Chapter was not found',
-    'guests_cannot_save_drafts' => 'Guests cannot save drafts',
+    'entity_not_found' => 'موجودیت یافت نشد',
+    'bookshelf_not_found' => 'قفسه کتاب پیدا نشد',
+    'book_not_found' => 'کتاب پیدا نشد',
+    'page_not_found' => 'صفحه یافت نشد',
+    'chapter_not_found' => 'فصل پیدا نشد',
+    'selected_book_not_found' => 'کتاب انتخابی یافت نشد',
+    'selected_book_chapter_not_found' => 'کتاب یا فصل انتخابی یافت نشد',
+    'guests_cannot_save_drafts' => 'مهمانان نمی توانند پیش نویس ها را ذخیره کنند',
 
     // Users
-    'users_cannot_delete_only_admin' => 'You cannot delete the only admin',
-    'users_cannot_delete_guest' => 'You cannot delete the guest user',
+    'users_cannot_delete_only_admin' => 'شما نمی توانید تنها ادمین را حذف کنید',
+    'users_cannot_delete_guest' => 'شما نمی توانید کاربر مهمان را حذف کنید',
 
     // Roles
-    'role_cannot_be_edited' => 'This role cannot be edited',
-    'role_system_cannot_be_deleted' => 'This role is a system role and cannot be deleted',
-    'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role',
-    'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.',
+    'role_cannot_be_edited' => 'این نقش قابل ویرایش نیست',
+    'role_system_cannot_be_deleted' => 'این نقش یک نقش سیستمی است و قابل حذف نیست',
+    'role_registration_default_cannot_delete' => 'این نقش در حالی که به عنوان نقش پیش فرض ثبت نام تنظیم شده است قابل حذف نیست',
+    'role_cannot_remove_only_admin' => 'این کاربر تنها کاربری است که به نقش مدیر اختصاص داده شده است. قبل از تلاش برای حذف آن در اینجا، نقش مدیر را به کاربر دیگری اختصاص دهید.',
 
     // Comments
-    'comment_list' => 'An error occurred while fetching the comments.',
-    'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.',
-    'comment_add' => 'An error occurred while adding / updating the comment.',
-    'comment_delete' => 'An error occurred while deleting the comment.',
-    'empty_comment' => 'Cannot add an empty comment.',
+    'comment_list' => 'هنگام واکشی نظرات خطایی روی داد.',
+    'cannot_add_comment_to_draft' => 'شما نمی توانید نظراتی را به یک پیش نویس اضافه کنید.',
+    'comment_add' => 'هنگام افزودن/به‌روزرسانی نظر خطایی روی داد.',
+    'comment_delete' => 'هنگام حذف نظر خطایی روی داد.',
+    'empty_comment' => 'نمی توان یک نظر خالی اضافه کرد.',
 
     // Error pages
-    '404_page_not_found' => 'Page Not Found',
-    'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.',
-    'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
-    'return_home' => 'Return to home',
-    'error_occurred' => 'An Error Occurred',
-    'app_down' => ':appName is down right now',
-    'back_soon' => 'It will be back up soon.',
+    '404_page_not_found' => 'صفحه یافت نشد',
+    'sorry_page_not_found' => 'با عرض پوزش، صفحه مورد نظر شما یافت نشد.',
+    'sorry_page_not_found_permission_warning' => 'اگر انتظار داشتید این صفحه وجود داشته باشد، ممکن است اجازه مشاهده آن را نداشته باشید.',
+    'image_not_found' => 'تصویر پیدا نشد',
+    'image_not_found_subtitle' => 'با عرض پوزش، فایل تصویری که به دنبال آن بودید یافت نشد.',
+    'image_not_found_details' => 'اگر انتظار داشتید این تصویر وجود داشته باشد، ممکن است حذف شده باشد.',
+    'return_home' => 'بازگشت به خانه',
+    'error_occurred' => 'خطایی رخ داد',
+    'app_down' => ':appName در حال حاضر قطع است',
+    'back_soon' => 'به زودی پشتیبان خواهد شد.',
 
     // API errors
-    'api_no_authorization_found' => 'No authorization token found on the request',
-    'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect',
-    'api_user_token_not_found' => 'No matching API token was found for the provided authorization token',
-    'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect',
-    'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls',
-    'api_user_token_expired' => 'The authorization token used has expired',
+    'api_no_authorization_found' => 'هیچ نشانه مجوزی در درخواست یافت نشد',
+    'api_bad_authorization_format' => 'یک نشانه مجوز در این درخواست یافت شد اما قالب نادرست به نظر می‌رسید',
+    'api_user_token_not_found' => 'هیچ نشانه API منطبقی برای کد مجوز ارائه شده یافت نشد',
+    'api_incorrect_token_secret' => 'راز ارائه شده برای کد API استفاده شده نادرست است',
+    'api_user_no_api_permission' => 'مالک نشانه API استفاده شده اجازه برقراری تماس های API را ندارد',
+    'api_user_token_expired' => 'رمز مجوز استفاده شده منقضی شده است',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'خطا در هنگام ارسال ایمیل آزمایشی:',
 
 ];
index 85bd12fc319557dcc852fdacc07e882583d66be3..22fb0b89e697e6d9e2ac4c49513eecced2a2cb47 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'previous' => '&laquo; Previous',
-    'next'     => 'Next &raquo;',
+    'previous' => '&laquo; قبلی',
+    'next'     => 'بعدی &raquo;',
 
 ];
index b408f3c2fdaf1e80e9cdafa36ae9507db9fbda48..06b8f8b50d69bd3b812c13ee0392e6f16d27e819 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => 'Passwords must be at least eight characters and match the confirmation.',
-    'user' => "We can't find a user with that e-mail address.",
-    'token' => 'The password reset token is invalid for this email address.',
-    'sent' => 'We have e-mailed your password reset link!',
-    'reset' => 'Your password has been reset!',
+    'password' => 'گذرواژه باید حداقل هشت حرف و با تایید مطابقت داشته باشد.',
+    'user' => "ما کاربری با این نشانی ایمیل نداریم.",
+    'token' => 'مشخصه‌ی بازگردانی رمز عبور معتبر نیست.',
+    'sent' => 'لینک بازگردانی رمز عبور به ایمیل شما ارسال شد!',
+    'reset' => 'رمز عبور شما بازگردانی شد!',
 
 ];
index ab7f153224afd41bb90e9cd54a9472895727b619..c03d064dabac3bdbe318f384252c3a3c4229c050 100644 (file)
 return [
 
     // Common Messages
-    'settings' => 'Settings',
-    'settings_save' => 'Save Settings',
-    'settings_save_success' => 'Settings saved',
+    'settings' => 'تنظیمات',
+    'settings_save' => 'تنظیمات را ذخیره کن',
+    'settings_save_success' => 'تنظیمات ذخیره شد',
 
     // App Settings
-    'app_customization' => 'Customization',
-    'app_features_security' => 'Features & Security',
-    'app_name' => 'Application Name',
-    'app_name_desc' => 'This name is shown in the header and in any system-sent emails.',
-    'app_name_header' => 'Show name in header',
-    'app_public_access' => 'Public Access',
-    'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.',
-    'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.',
-    'app_public_access_toggle' => 'Allow public access',
-    'app_public_viewing' => 'Allow public viewing?',
-    'app_secure_images' => 'Higher Security Image Uploads',
-    'app_secure_images_toggle' => 'Enable higher security image uploads',
-    'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.',
-    'app_editor' => 'Page Editor',
-    'app_editor_desc' => 'Select which editor will be used by all users to edit pages.',
-    'app_custom_html' => 'Custom HTML Head Content',
-    'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
-    'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
-    'app_logo' => 'Application Logo',
-    'app_logo_desc' => 'This image should be 43px in height. <br>Large images will be scaled down.',
-    'app_primary_color' => 'Application Primary Color',
-    'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.',
-    'app_homepage' => 'Application Homepage',
-    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_select' => 'Select a page',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
-    'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
-    'app_disable_comments' => 'Disable Comments',
-    'app_disable_comments_toggle' => 'Disable comments',
-    'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
+    'app_customization' => 'سفارشی سازی',
+    'app_features_security' => 'ویژگی ها و امنیت',
+    'app_name' => 'نام نرم افزار',
+    'app_name_desc' => 'این نام در هدر و در هر ایمیل ارسال شده توسط سیستم نشان داده شده است.',
+    'app_name_header' => 'نمایش نام در هدر',
+    'app_public_access' => 'دسترسی عمومی',
+    'app_public_access_desc' => 'فعال کردن این گزینه به بازدیدکنندگانی که وارد سیستم نشده‌اند اجازه می‌دهد تا به محتوای موجود در نمونه BookStack شما دسترسی داشته باشند.',
+    'app_public_access_desc_guest' => 'دسترسی بازدیدکنندگان عمومی را می توان از طریق کاربر "مهمان" کنترل کرد.',
+    'app_public_access_toggle' => 'اجازه دسترسی عمومی',
+    'app_public_viewing' => 'مشاهده عمومی مجاز است؟',
+    'app_secure_images' => 'آپلود تصویر با امنیت بالاتر',
+    'app_secure_images_toggle' => 'آپلود تصویر با امنیت بالاتر',
+    'app_secure_images_desc' => 'به دلایل عملکرد، همه تصاویر عمومی هستند. این گزینه یک رشته تصادفی و غیرقابل حدس زدن را در مقابل آدرس های تصویر اضافه می کند. برای جلوگیری از دسترسی آسان، اطمینان حاصل کنید که فهرست های دایرکتوری فعال نیستند.',
+    'app_editor' => 'ویرایشگر صفحه',
+    'app_editor_desc' => 'انتخاب کنید کدام ویرایشگر توسط همه کاربران برای ویرایش صفحات استفاده شود.',
+    'app_custom_html' => 'محتوای اصلی HTML سفارشی',
+    'app_custom_html_desc' => 'هر محتوای اضافه شده در اینجا در پایین بخش <head> هر صفحه درج می شود. این برای تغییر سبک ها یا اضافه کردن کد تجزیه و تحلیل مفید است.',
+    'app_custom_html_disabled_notice' => 'محتوای سر HTML سفارشی در این صفحه تنظیمات غیرفعال است تا اطمینان حاصل شود که هر گونه تغییر شکسته می تواند برگردانده شود.',
+    'app_logo' => 'لوگوی برنامه',
+    'app_logo_desc' => 'این تصویر باید 43 پیکسل ارتفاع داشته باشد. <br>تصاویر بزرگ کوچک می شوند.',
+    'app_primary_color' => 'رنگ اصلی برنامه',
+    'app_primary_color_desc' => 'رنگ اصلی برنامه را از جمله بنر، دکمه ها و پیوندها تنظیم می کند.',
+    'app_homepage' => 'صفحه اصلی برنامه',
+    'app_homepage_desc' => 'به جای نمای پیش‌فرض، یک نمای را برای نمایش در صفحه اصلی انتخاب کنید. مجوزهای صفحه برای صفحات انتخابی نادیده گرفته می شود.',
+    'app_homepage_select' => 'یک صفحه را انتخاب کنید',
+    'app_footer_links' => 'پیوندهای پاورقی',
+    'app_footer_links_desc' => 'پیوندهایی را برای نمایش در پاورقی سایت اضافه کنید. اینها در پایین اکثر صفحات نمایش داده می شوند، از جمله صفحاتی که نیازی به ورود به سیستم ندارند. می توانید از برچسب "trans::<key>" برای استفاده از ترجمه های تعریف شده توسط سیستم استفاده کنید. به عنوان مثال: با استفاده از "trans::common.privacy_policy" متن ترجمه شده "خط مشی رازداری" و "trans::common.terms_of_service" متن ترجمه شده "شرایط خدمات" را ارائه می دهد.',
+    'app_footer_links_label' => 'برچسب پیوند',
+    'app_footer_links_url' => 'لینک URL',
+    'app_footer_links_add' => 'پیوند پاورقی را اضافه کنید',
+    'app_disable_comments' => 'غیرفعال کردن نظرات',
+    'app_disable_comments_toggle' => 'نظرات را غیرفعال کنید',
+    'app_disable_comments_desc' => 'نظرات را در تمام صفحات برنامه غیرفعال می کند. <br> نظرات موجود نشان داده نمی شوند.',
 
     // Color settings
-    'content_colors' => 'Content Colors',
-    'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
-    'bookshelf_color' => 'Shelf Color',
-    'book_color' => 'Book Color',
-    'chapter_color' => 'Chapter Color',
-    'page_color' => 'Page Color',
-    'page_draft_color' => 'Page Draft Color',
+    'content_colors' => 'رنگ های محتوا',
+    'content_colors_desc' => 'رنگ ها را برای همه عناصر در سلسله مراتب سازمان صفحه تنظیم می کند. برای خوانایی، انتخاب رنگ هایی با روشنایی مشابه با رنگ های پیش فرض توصیه می شود.',
+    'bookshelf_color' => 'رنگ قفسه',
+    'book_color' => 'رنگ کتاب',
+    'chapter_color' => 'رنگ فصل',
+    'page_color' => 'رنگ صفحه',
+    'page_draft_color' => 'رنگ پیش نویس صفحه',
 
     // Registration Settings
-    'reg_settings' => 'Registration',
-    'reg_enable' => 'Enable Registration',
-    'reg_enable_toggle' => 'Enable registration',
-    'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.',
-    'reg_default_role' => 'Default user role after registration',
-    'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.',
-    'reg_email_confirmation' => 'Email Confirmation',
-    'reg_email_confirmation_toggle' => 'Require email confirmation',
-    'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.',
-    'reg_confirm_restrict_domain' => 'Domain Restriction',
-    'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.',
-    'reg_confirm_restrict_domain_placeholder' => 'No restriction set',
+    'reg_settings' => 'ثبت نام',
+    'reg_enable' => 'فعال کردن ثبت نام',
+    'reg_enable_toggle' => 'فعال کردن ثبت نام',
+    'reg_enable_desc' => 'هنگامی که ثبت نام فعال باشد، کاربر می تواند خود را به عنوان کاربر برنامه ثبت نام کند. پس از ثبت نام به آنها یک نقش کاربر پیش فرض داده می شود.',
+    'reg_default_role' => 'نقش کاربر پیش فرض پس از ثبت نام',
+    'reg_enable_external_warning' => 'هنگامی که احراز هویت خارجی LDAP یا SAML فعال است، گزینه بالا نادیده گرفته می شود. در صورتی که احراز هویت، در برابر سیستم خارجی در حال استفاده، موفقیت آمیز باشد، حساب های کاربری برای اعضای غیر موجود به طور خودکار ایجاد می شود.',
+    'reg_email_confirmation' => 'تایید ایمیل',
+    'reg_email_confirmation_toggle' => 'نیاز به تایید ایمیل',
+    'reg_confirm_email_desc' => 'در صورت استفاده از محدودیت دامنه، تایید ایمیل مورد نیاز است و این گزینه نادیده گرفته می شود.',
+    'reg_confirm_restrict_domain' => 'محدودیت دامنه',
+    'reg_confirm_restrict_domain_desc' => 'فهرستی از دامنه‌های ایمیل جدا شده با کاما را وارد کنید که می‌خواهید ثبت نام را محدود کنید. قبل از اینکه به کاربران اجازه تعامل با برنامه داده شود، ایمیلی برای تأیید آدرس آنها ارسال می شود. <br> توجه داشته باشید که کاربران پس از ثبت نام موفق می توانند آدرس ایمیل خود را تغییر دهند.',
+    'reg_confirm_restrict_domain_placeholder' => 'بدون محدودیت',
 
     // Maintenance settings
-    'maint' => 'Maintenance',
-    'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
-    'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
-    'maint_image_cleanup_run' => 'Run Cleanup',
-    'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
-    'maint_image_cleanup_success' => ':count potentially unused images found and deleted!',
-    'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!',
-    'maint_send_test_email' => 'Send a Test Email',
-    'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.',
-    'maint_send_test_email_run' => 'Send test email',
-    'maint_send_test_email_success' => 'Email sent to :address',
-    'maint_send_test_email_mail_subject' => 'Test Email',
-    'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
-    'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
-    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
-    'maint_recycle_bin_open' => 'Open Recycle Bin',
+    'maint' => 'نگهداری',
+    'maint_image_cleanup' => 'پاکسازی تصاویر',
+    'maint_image_cleanup_desc' => 'محتوای صفحه و بازبینی را اسکن می‌کند تا بررسی کند که کدام تصاویر و نقاشی‌ها در حال حاضر استفاده می‌شوند و کدام تصاویر اضافی هستند. قبل از اجرای این کار، مطمئن شوید که یک پایگاه داده کامل و یک نسخه پشتیبان از تصویر ایجاد کرده اید.',
+    'maint_delete_images_only_in_revisions' => 'همچنین تصاویری را که فقط در ویرایش های صفحه قدیمی وجود دارند حذف کنید',
+    'maint_image_cleanup_run' => 'پاکسازی را اجرا کنید',
+    'maint_image_cleanup_warning' => ':count تصاویر بالقوه استفاده نشده پیدا شد. آیا مطمئن هستید که می خواهید این تصاویر را حذف کنید؟',
+    'maint_image_cleanup_success' => ':count تصویر بالقوه استفاده نشده پیدا و حذف شد!',
+    'maint_image_cleanup_nothing_found' => 'هیچ تصویر استفاده نشده ای یافت نشد، چیزی حذف نشد!',
+    'maint_send_test_email' => 'یک ایمیل آزمایشی ارسال کنید',
+    'maint_send_test_email_desc' => 'این یک ایمیل آزمایشی به آدرس ایمیل شما مشخص شده در نمایه شما ارسال می کند.',
+    'maint_send_test_email_run' => 'ارسال ایمیل آزمایشی',
+    'maint_send_test_email_success' => 'ایمیل به آدرس :address ارسال شد',
+    'maint_send_test_email_mail_subject' => 'تست ایمیل',
+    'maint_send_test_email_mail_greeting' => 'به نظر می رسد تحویل ایمیل کار می کند!',
+    'maint_send_test_email_mail_text' => 'تبریک می گویم! با دریافت این اعلان ایمیل، به نظر می رسد تنظیمات ایمیل شما به درستی پیکربندی شده است.',
+    'maint_recycle_bin_desc' => 'قفسه‌ها، کتاب‌ها، فصل‌ها و صفحات حذف‌شده به سطل بازیافت فرستاده می‌شوند تا بتوان آن‌ها را بازیابی کرد یا برای همیشه حذف کرد. بسته به پیکربندی سیستم، اقلام قدیمی در سطل بازیافت ممکن است پس از مدتی به طور خودکار حذف شوند.',
+    'maint_recycle_bin_open' => 'سطل بازیافت را باز کنید',
 
     // Recycle Bin
-    'recycle_bin' => 'Recycle Bin',
-    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
-    'recycle_bin_deleted_by' => 'Deleted By',
-    'recycle_bin_deleted_at' => 'Deletion Time',
-    'recycle_bin_permanently_delete' => 'Permanently Delete',
-    'recycle_bin_restore' => 'Restore',
-    'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
-    'recycle_bin_empty' => 'Empty Recycle Bin',
-    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
-    'recycle_bin_restore_list' => 'Items to be Restored',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
-    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin' => 'سطل زباله',
+    'recycle_bin_desc' => 'در اینجا می توانید مواردی را که حذف شده اند بازیابی کنید یا حذف دائمی آنها را از سیستم انتخاب کنید. این لیست برخلاف لیست‌های فعالیت مشابه در سیستمی که فیلترهای مجوز اعمال می‌شوند، فیلتر نشده است.',
+    'recycle_bin_deleted_item' => 'مورد حذف شده',
+    'recycle_bin_deleted_parent' => 'والد',
+    'recycle_bin_deleted_by' => 'حذف شده توسط',
+    'recycle_bin_deleted_at' => 'زمان حذف',
+    'recycle_bin_permanently_delete' => 'برای همیشه حذف کنید',
+    'recycle_bin_restore' => 'بازگرداندن',
+    'recycle_bin_contents_empty' => 'سطل بازیافت در حال حاضر خالی است',
+    'recycle_bin_empty' => 'سطل آشغال خالی',
+    'recycle_bin_empty_confirm' => 'این کار همه اقلام موجود در سطل بازیافت از جمله محتوای موجود در هر مورد را برای همیشه از بین می برد. آیا مطمئن هستید که می خواهید سطل بازیافت را خالی کنید؟',
+    'recycle_bin_destroy_confirm' => 'این اقدام این مورد را به همراه هر عنصر فرعی فهرست شده در زیر برای همیشه از سیستم حذف می کند و شما نمی توانید این محتوا را بازیابی کنید. آیا مطمئن هستید که می خواهید این مورد را برای همیشه حذف کنید؟',
+    'recycle_bin_destroy_list' => 'مواردی که باید نابود شوند',
+    'recycle_bin_restore_list' => 'مواردی که باید بازیابی شوند',
+    'recycle_bin_restore_confirm' => 'این اقدام، مورد حذف شده، از جمله هر عنصر فرزند، را به مکان اصلی خود باز می گرداند. اگر مکان اصلی از آن زمان حذف شده باشد، و اکنون در سطل بازیافت است، مورد اصلی نیز باید بازیابی شود.',
+    'recycle_bin_restore_deleted_parent' => 'والد این مورد نیز حذف شده است. تا زمانی که آن والد نیز بازیابی نشود، این موارد حذف خواهند شد.',
+    'recycle_bin_restore_parent' => 'بازیابی والد',
+    'recycle_bin_destroy_notification' => ':count تعداد از کل اقلام از سطل بازیافت حذف شده.',
+    'recycle_bin_restore_notification' => ':count تعداد از کل اقلام از سطل بازیافت بازیابی شده.',
 
     // Audit Log
-    'audit' => 'Audit Log',
-    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'audit_event_filter' => 'Event Filter',
-    'audit_event_filter_no_filter' => 'No Filter',
-    'audit_deleted_item' => 'Deleted Item',
-    'audit_deleted_item_name' => 'Name: :name',
-    'audit_table_user' => 'User',
-    'audit_table_event' => 'Event',
-    'audit_table_related' => 'Related Item or Detail',
-    'audit_table_date' => 'Activity Date',
-    'audit_date_from' => 'Date Range From',
-    'audit_date_to' => 'Date Range To',
+    'audit' => 'گزارش حسابرسی',
+    'audit_desc' => 'این گزارش حسابرسی لیستی از فعالیت های ردیابی شده در سیستم را نمایش می دهد. این لیست برخلاف لیست‌های فعالیت مشابه در سیستمی که فیلترهای مجوز اعمال می‌شوند، فیلتر نشده است.',
+    'audit_event_filter' => 'فیلتر رویداد',
+    'audit_event_filter_no_filter' => 'بدون فیلتر',
+    'audit_deleted_item' => 'مورد حذف شده',
+    'audit_deleted_item_name' => 'نام  :name',
+    'audit_table_user' => 'نام كاربري',
+    'audit_table_event' => 'رویداد',
+    'audit_table_related' => 'مورد یا جزئیات مرتبط',
+    'audit_table_ip' => 'آدرس IP',
+    'audit_table_date' => 'تاریخ‌های فعالیت',
+    'audit_date_from' => 'محدوده تاریخ از',
+    'audit_date_to' => 'محدوده تاریخ تا',
 
     // Role Settings
-    'roles' => 'Roles',
-    'role_user_roles' => 'User Roles',
-    'role_create' => 'Create New Role',
-    'role_create_success' => 'Role successfully created',
-    'role_delete' => 'Delete Role',
-    'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.',
-    'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.',
-    'role_delete_no_migration' => "Don't migrate users",
-    'role_delete_sure' => 'Are you sure you want to delete this role?',
-    'role_delete_success' => 'Role successfully deleted',
-    'role_edit' => 'Edit Role',
-    'role_details' => 'Role Details',
-    'role_name' => 'Role Name',
-    'role_desc' => 'Short Description of Role',
-    'role_external_auth_id' => 'External Authentication IDs',
-    'role_system' => 'System Permissions',
-    'role_manage_users' => 'Manage users',
-    'role_manage_roles' => 'Manage roles & role permissions',
-    'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions',
-    'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages',
-    'role_manage_page_templates' => 'Manage page templates',
-    'role_access_api' => 'Access system API',
-    'role_manage_settings' => 'Manage app settings',
-    '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_all' => 'All',
-    'role_own' => 'Own',
-    'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
-    'role_save' => 'Save Role',
-    'role_update_success' => 'Role successfully updated',
-    'role_users' => 'Users in this role',
-    'role_users_none' => 'No users are currently assigned to this role',
+    'roles' => 'نقش ها',
+    'role_user_roles' => 'نقش های کاربر',
+    'role_create' => 'نقش جدید ایجاد کنید',
+    'role_create_success' => 'نقش با موفقیت ایجاد شد',
+    'role_delete' => 'حذف نقش',
+    'role_delete_confirm' => 'با این کار نقش با نام \':roleName\' حذف می شود.',
+    'role_delete_users_assigned' => 'این نقش دارای :userCount کاربرانی است که به آن اختصاص داده شده است. اگر می خواهید کاربران را از این نقش مهاجرت کنید، نقش جدیدی را در زیر انتخاب کنید.',
+    'role_delete_no_migration' => "کاربران را منتقل نکنید",
+    'role_delete_sure' => 'آیا مطمئنید که می خواهید این نقش را حذف کنید؟',
+    'role_delete_success' => 'نقش با موفقیت حذف شد',
+    'role_edit' => 'ویرایش نقش',
+    'role_details' => 'جزئیات نقش',
+    'role_name' => 'اسم نقش',
+    'role_desc' => 'شرح کوتاه نقش',
+    'role_mfa_enforced' => 'به احراز هویت چند عاملی نیاز دارد',
+    'role_external_auth_id' => 'شناسه های تأیید هویت خارجی',
+    'role_system' => 'مجوزهای سیستم',
+    'role_manage_users' => 'مدیریت کاربران',
+    'role_manage_roles' => 'نقش ها و مجوزهای نقش را مدیریت کنید',
+    'role_manage_entity_permissions' => 'تمام مجوزهای کتاب، فصل و صفحه را مدیریت کنید',
+    'role_manage_own_entity_permissions' => 'مجوزها را در کتاب، فصل و صفحات خود مدیریت کنید',
+    'role_manage_page_templates' => 'مدیریت قالب های صفحه',
+    'role_access_api' => 'دسترسی به API سیستم',
+    'role_manage_settings' => 'تنظیمات برنامه را مدیریت کنید',
+    'role_export_content' => 'صادرات محتوا',
+    'role_asset' => 'مجوزهای دارایی',
+    'roles_system_warning' => 'توجه داشته باشید که دسترسی به هر یک از سه مجوز فوق می‌تواند به کاربر اجازه دهد تا امتیازات خود یا امتیازات دیگران را در سیستم تغییر دهد. فقط نقش هایی را با این مجوزها به کاربران مورد اعتماد اختصاص دهید.',
+    'role_asset_desc' => 'این مجوزها دسترسی پیش‌فرض به دارایی‌های درون سیستم را کنترل می‌کنند. مجوزهای مربوط به کتاب‌ها، فصل‌ها و صفحات این مجوزها را لغو می‌کنند.',
+    'role_asset_admins' => 'به ادمین‌ها به‌طور خودکار به همه محتوا دسترسی داده می‌شود، اما این گزینه‌ها ممکن است گزینه‌های UI را نشان داده یا پنهان کنند.',
+    'role_all' => 'همه',
+    'role_own' => 'صاحب',
+    'role_controlled_by_asset' => 'توسط دارایی که در آن آپلود می شود کنترل می شود',
+    'role_save' => 'ذخیره نقش',
+    'role_update_success' => 'نقش با موفقیت به روز شد',
+    'role_users' => 'کاربران در این نقش',
+    'role_users_none' => 'در حال حاضر هیچ کاربری به این نقش اختصاص داده نشده است',
 
     // Users
-    'users' => 'Users',
-    'user_profile' => 'User Profile',
-    'users_add_new' => 'Add New User',
-    'users_search' => 'Search Users',
-    'users_latest_activity' => 'Latest Activity',
-    'users_details' => 'User Details',
-    'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
-    'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
-    'users_role' => 'User Roles',
-    'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
-    'users_password' => 'User Password',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
-    'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
-    'users_send_invite_option' => 'Send user invite email',
-    'users_external_auth_id' => 'External Authentication ID',
-    'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
-    'users_password_warning' => 'Only fill the below if you would like to change your password.',
-    'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.',
-    'users_delete' => 'Delete User',
-    'users_delete_named' => 'Delete user :userName',
-    'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.',
-    'users_delete_confirm' => 'Are you sure you want to delete this user?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
-    'users_none_selected' => 'No user selected',
-    'users_delete_success' => 'User successfully removed',
-    'users_edit' => 'Edit User',
-    'users_edit_profile' => 'Edit Profile',
-    'users_edit_success' => 'User successfully updated',
-    'users_avatar' => 'User Avatar',
-    'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.',
-    'users_preferred_language' => 'Preferred Language',
-    'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.',
-    'users_social_accounts' => 'Social Accounts',
-    'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.',
-    'users_social_connect' => 'Connect Account',
-    'users_social_disconnect' => 'Disconnect Account',
-    'users_social_connected' => ':socialAccount account was successfully attached to your profile.',
-    'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.',
-    'users_api_tokens' => 'API Tokens',
-    'users_api_tokens_none' => 'No API tokens have been created for this user',
-    'users_api_tokens_create' => 'Create Token',
-    'users_api_tokens_expires' => 'Expires',
-    'users_api_tokens_docs' => 'API Documentation',
+    'users' => 'کاربران',
+    'user_profile' => 'پرونده کاربر',
+    'users_add_new' => 'افزودن کاربر جدید',
+    'users_search' => 'جستجوی کاربران',
+    'users_latest_activity' => 'آخرین فعالیت',
+    'users_details' => 'جزئیات کاربر',
+    'users_details_desc' => 'یک نام نمایشی و یک آدرس ایمیل برای این کاربر تنظیم کنید. آدرس ایمیل برای ورود به برنامه استفاده خواهد شد.',
+    'users_details_desc_no_email' => 'یک نام نمایشی برای این کاربر تنظیم کنید تا دیگران بتوانند آنها را تشخیص دهند.',
+    'users_role' => 'نقش های کاربر',
+    'users_role_desc' => 'انتخاب کنید که این کاربر به کدام نقش ها اختصاص داده شود. اگر یک کاربر به چندین نقش اختصاص داده شود، مجوزهای آن نقش‌ها روی هم قرار می‌گیرند و تمام توانایی‌های نقش‌های اختصاص داده شده را دریافت خواهند کرد.',
+    'users_password' => 'رمز عبور كاربر',
+    'users_password_desc' => 'رمز عبوری را که برای ورود به برنامه استفاده می شود تنظیم کنید. این باید حداقل 8 کاراکتر باشد.',
+    'users_send_invite_text' => 'می توانید انتخاب کنید که برای این کاربر یک ایمیل دعوت نامه ارسال شود که به آنها امکان می دهد رمز عبور خود را تعیین کنند در غیر این صورت می توانید رمز عبور خود را تعیین کنید.',
+    'users_send_invite_option' => 'ارسال ایمیل دعوت کاربر',
+    'users_external_auth_id' => 'شناسه احراز هویت خارجی',
+    'users_external_auth_id_desc' => 'این شناسه ای است که برای مطابقت با این کاربر هنگام برقراری ارتباط با سیستم احراز هویت خارجی شما استفاده می شود.',
+    'users_password_warning' => 'فقط در صورتی که مایل به تغییر رمز عبور خود هستید، موارد زیر را پر کنید.',
+    'users_system_public' => 'این کاربر نماینده هر کاربر مهمانی است که از نمونه شما بازدید می کند. نمی توان از آن برای ورود استفاده کرد اما به طور خودکار اختصاص داده می شود.',
+    'users_delete' => 'حذف کاربر',
+    'users_delete_named' => 'حذف :userName',
+    'users_delete_warning' => 'با این کار این کاربر با نام \':userName\' به طور کامل از سیستم حذف می شود.',
+    'users_delete_confirm' => 'آیا مطمئن هستید که می خواهید این کاربر را حذف کنید؟',
+    'users_migrate_ownership' => 'انتقال مالکیت',
+    'users_migrate_ownership_desc' => 'اگر می‌خواهید کاربر دیگری مالک همه مواردی باشد که در حال حاضر متعلق به این کاربر است، کاربری را در اینجا انتخاب کنید.',
+    'users_none_selected' => 'هیچ کاربری انتخاب نشد',
+    'users_delete_success' => 'کاربر با موفقیت حذف شد',
+    'users_edit' => 'ویرایش کاربر',
+    'users_edit_profile' => 'ویرایش پروفایل',
+    'users_edit_success' => 'کاربر با موفقیت به روز شد',
+    'users_avatar' => 'آواتار کاربر',
+    'users_avatar_desc' => 'تصویری را برای نشان دادن این کاربر انتخاب کنید. این باید تقریباً 256 پیکسل مربع باشد.',
+    'users_preferred_language' => 'زبان ترجیحی',
+    'users_preferred_language_desc' => 'این گزینه زبان مورد استفاده برای رابط کاربری برنامه را تغییر می دهد. این روی محتوای ایجاد شده توسط کاربر تأثیری نخواهد داشت.',
+    'users_social_accounts' => 'حساب های اجتماعی',
+    'users_social_accounts_info' => 'در اینجا می‌توانید حساب‌های دیگر خود را برای ورود سریع‌تر و آسان‌تر متصل کنید. قطع ارتباط حساب در اینجا، دسترسی مجاز قبلی را لغو نمی کند. دسترسی را از تنظیمات نمایه خود در حساب اجتماعی متصل لغو کنید.',
+    'users_social_connect' => 'اتصال حساب کاربری',
+    'users_social_disconnect' => 'قطع حساب',
+    'users_social_connected' => 'حساب :socialAccount با موفقیت به نمایه شما پیوست شد.',
+    'users_social_disconnected' => 'حساب :socialAccount با موفقیت از نمایه شما قطع شد.',
+    'users_api_tokens' => 'توکن‌های API',
+    'users_api_tokens_none' => 'هیچ نشانه API برای این کاربر ایجاد نشده است',
+    'users_api_tokens_create' => 'ایجاد توکن',
+    'users_api_tokens_expires' => 'منقضی شده ها',
+    'users_api_tokens_docs' => 'مستندات API',
+    'users_mfa' => 'احراز هویت چند عاملی',
+    'users_mfa_desc' => 'تنظیم احراز هویت چند مرحله ای یک لایه امنیتی دیگر به حساب شما اضافه میکند.',
+    'users_mfa_x_methods' => ':count روش پیکربندی شده است|:count روش های پیکربندی شده',
+    'users_mfa_configure' => 'روش پیکربندی',
 
     // API Tokens
-    'user_api_token_create' => 'Create API Token',
-    'user_api_token_name' => 'Name',
-    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
-    'user_api_token_expiry' => 'Expiry Date',
-    'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
-    'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
-    'user_api_token_create_success' => 'API token successfully created',
-    'user_api_token_update_success' => 'API token successfully updated',
-    'user_api_token' => 'API Token',
-    'user_api_token_id' => 'Token ID',
-    'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
-    'user_api_token_secret' => 'Token Secret',
-    'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.',
-    'user_api_token_created' => 'Token created :timeAgo',
-    'user_api_token_updated' => 'Token updated :timeAgo',
-    'user_api_token_delete' => 'Delete Token',
-    'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
-    'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
-    'user_api_token_delete_success' => 'API token successfully deleted',
+    'user_api_token_create' => 'ایجاد توکن API',
+    'user_api_token_name' => 'نام',
+    'user_api_token_name_desc' => 'توکن خود را به عنوان یادآوری هدف مورد نظر در آینده، نامی خوانا بدهید.',
+    'user_api_token_expiry' => 'تاریخ انقضا',
+    'user_api_token_expiry_desc' => 'تاریخی را تعیین کنید که در آن این توکن منقضی شود. پس از این تاریخ، درخواست‌هایی که با استفاده از این رمز انجام می‌شوند دیگر کار نمی‌کنند. خالی گذاشتن این فیلد باعث انقضای 100 سال آینده می شود.',
+    'user_api_token_create_secret_message' => 'بلافاصله پس از ایجاد این توکن یک "شناسه رمز" و "رمز رمز" تولید و نمایش داده می شود. راز فقط یک بار نشان داده می‌شود، بنابراین قبل از ادامه، حتماً مقدار را در جایی امن و مطمئن کپی کنید.',
+    'user_api_token_create_success' => 'توکن API با موفقیت ایجاد شد',
+    'user_api_token_update_success' => 'توکن API با موفقیت به روز شد',
+    'user_api_token' => 'توکن API',
+    'user_api_token_id' => 'شناسه توکن',
+    'user_api_token_id_desc' => 'این یک شناسه غیرقابل ویرایش است که برای این نشانه ایجاد شده است که باید در درخواست‌های API ارائه شود.',
+    'user_api_token_secret' => 'رمز توکن',
+    'user_api_token_secret_desc' => 'این یک راز ایجاد شده توسط سیستم برای این نشانه است که باید در درخواست های API ارائه شود. این فقط یک بار نمایش داده می شود، بنابراین این مقدار را در جایی امن و مطمئن کپی کنید.',
+    'user_api_token_created' => 'توکن ایجاد شد :timeAgo',
+    'user_api_token_updated' => 'توکن به روز شد :timeAgo',
+    'user_api_token_delete' => 'توکن را حذف کنید',
+    'user_api_token_delete_warning' => 'با این کار این نشانه API با نام \':tokenName\' به طور کامل از سیستم حذف می شود.',
+    'user_api_token_delete_confirm' => 'آیا مطمئن هستید که می خواهید این نشانه API را حذف کنید؟',
+    'user_api_token_delete_success' => 'توکن API با موفقیت حذف شد',
+
+    // Webhooks
+    'webhooks' => 'وب هوک ها',
+    'webhooks_create' => 'ایجاد وب هوک جدید',
+    'webhooks_none_created' => 'هنوز هیچ وب هوکی ایجاد نشده است.',
+    'webhooks_edit' => 'ویرایش وب هوک',
+    'webhooks_save' => 'ذخیره وب هوک',
+    'webhooks_details' => 'جزئیات وب هوک',
+    'webhooks_details_desc' => 'یک نام کاربر پسند و یک نقطه پایانی POST به عنوان مکانی برای ارسال داده های وب هوک ارائه دهید.',
+    'webhooks_events' => 'رویدادهای وب هوک',
+    'webhooks_events_desc' => 'تمام رویدادهایی را که باید باعث فراخوانی این وب هوک شوند، انتخاب کنید.',
+    'webhooks_events_warning' => 'به خاطر داشته باشید که این رویدادها برای همه رویدادهای انتخابی فعال خواهند شد، حتی اگر مجوزهای سفارشی اعمال شوند. مطمئن شوید که استفاده از این وب هوک محتوای محرمانه را فاش نمی کند.',
+    'webhooks_events_all' => 'تمام رویدادهای سیستم',
+    'webhooks_name' => 'نام وب هوک',
+    'webhooks_timeout' => 'مهلت درخواست وب هوک (ثانیه)',
+    'webhooks_endpoint' => 'نقطه پایانی وب هوک',
+    'webhooks_active' => 'وب هوک فعال',
+    'webhook_events_table_header' => 'رویدادها',
+    'webhooks_delete' => 'حذف وب هوک',
+    'webhooks_delete_warning' => 'با این کار این وب هوک با نام \':webhookName\' به طور کامل از سیستم حذف می شود.',
+    'webhooks_delete_confirm' => 'آیا مطمئن هستید که می خواهید این وب هوک را حذف کنید؟',
+    'webhooks_format_example' => 'نمونه قالب وب هوک',
+    'webhooks_format_example_desc' => 'داده‌های وب هوک به‌عنوان یک درخواست POST به نقطه پایانی پیکربندی‌شده به‌عنوان JSON با فرمت زیر ارسال می‌شوند. ویژگی های "related_item" و "url" اختیاری هستند و به نوع رویداد راه اندازی شده بستگی دارد.',
+    'webhooks_status' => 'وضعیت وب هوک',
+    'webhooks_last_called' => 'آخرین تماس:',
+    'webhooks_last_errored' => 'آخرین خطا:',
+    'webhooks_last_error_message' => 'آخرین پیغام خطا:',
+
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 4031de2ae743b75bafd49195ff06b29a347cbf22..665451e8de437fd53a110b819c59e1f2dcd4ad49 100644 (file)
 return [
 
     // Standard laravel validation lines
-    'accepted'             => 'The :attribute must be accepted.',
-    'active_url'           => 'The :attribute is not a valid URL.',
-    'after'                => 'The :attribute must be a date after :date.',
-    '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.',
-    'before'               => 'The :attribute must be a date before :date.',
+    'accepted'             => ':attribute باید پذیرفته شده باشد.',
+    'active_url'           => 'آدرس :attribute معتبر نیست.',
+    'after'                => ':attribute باید تاریخی بعد از :date باشد.',
+    'alpha'                => ':attribute باید فقط حروف الفبا باشد.',
+    'alpha_dash'           => ':attribute باید فقط حروف الفبا، اعداد، خط تیره و زیرخط باشد.',
+    'alpha_num'            => ':attribute باید فقط حروف الفبا و اعداد باشد.',
+    'array'                => ':attribute باید آرایه باشد.',
+    'backup_codes'         => 'کد ارائه شده معتبر نیست یا قبلا استفاده شده است.',
+    'before'               => ':attribute باید تاریخی قبل از :date باشد.',
     'between'              => [
-        'numeric' => 'The :attribute must be between :min and :max.',
-        'file'    => 'The :attribute must be between :min and :max kilobytes.',
-        'string'  => 'The :attribute must be between :min and :max characters.',
-        'array'   => 'The :attribute must have between :min and :max items.',
+        'numeric' => ':attribute باید بین :min و :max باشد.',
+        'file'    => ':attribute باید بین :min و :max کیلوبایت باشد.',
+        'string'  => ':attribute باید بین :min و :max کاراکتر باشد.',
+        'array'   => ':attribute باید بین :min و :max آیتم باشد.',
     ],
-    'boolean'              => 'The :attribute field must be true or false.',
-    'confirmed'            => 'The :attribute confirmation does not match.',
-    'date'                 => 'The :attribute is not a valid date.',
-    'date_format'          => 'The :attribute does not match the format :format.',
-    'different'            => 'The :attribute and :other must be different.',
-    'digits'               => 'The :attribute must be :digits digits.',
-    'digits_between'       => 'The :attribute must be between :min and :max digits.',
-    'email'                => 'The :attribute must be a valid email address.',
-    'ends_with' => 'The :attribute must end with one of the following: :values',
-    'filled'               => 'The :attribute field is required.',
+    'boolean'              => 'فیلد :attribute فقط می‌تواند true و یا false باشد.',
+    'confirmed'            => ':attribute با فیلد تکرار مطابقت ندارد.',
+    'date'                 => ':attribute یک تاریخ معتبر نیست.',
+    'date_format'          => ':attribute با الگوی :format مطابقت ندارد.',
+    'different'            => ':attribute و :other باید از یکدیگر متفاوت باشند.',
+    'digits'               => ':attribute باید :digits رقم باشد.',
+    'digits_between'       => ':attribute باید بین :min و :max رقم باشد.',
+    'email'                => ':attribute باید یک ایمیل معتبر باشد.',
+    'ends_with' => 'فیلد :attribute باید با یکی از مقادیر زیر خاتمه یابد: :values',
+    'filled'               => 'فیلد :attribute باید مقدار داشته باشد.',
     'gt'                   => [
-        'numeric' => 'The :attribute must be greater than :value.',
-        'file'    => 'The :attribute must be greater than :value kilobytes.',
-        'string'  => 'The :attribute must be greater than :value characters.',
-        'array'   => 'The :attribute must have more than :value items.',
+        'numeric' => ':attribute باید بزرگتر از :value باشد.',
+        'file'    => ':attribute باید بزرگتر از :value کیلوبایت باشد.',
+        'string'  => ':attribute باید بیشتر از :value کاراکتر داشته باشد.',
+        'array'   => ':attribute باید بیشتر از :value آیتم داشته باشد.',
     ],
     'gte'                  => [
-        'numeric' => 'The :attribute must be greater than or equal :value.',
-        'file'    => 'The :attribute must be greater than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be greater than or equal :value characters.',
-        'array'   => 'The :attribute must have :value items or more.',
+        'numeric' => ':attribute باید بزرگتر یا مساوی :value باشد.',
+        'file'    => ':attribute باید بزرگتر یا مساوی :value کیلوبایت باشد.',
+        'string'  => ':attribute باید بیشتر یا مساوی :value کاراکتر داشته باشد.',
+        'array'   => ':attribute باید بیشتر یا مساوی :value آیتم داشته باشد.',
     ],
-    'exists'               => 'The selected :attribute is invalid.',
-    'image'                => 'The :attribute must be an image.',
-    'image_extension'      => 'The :attribute must have a valid & supported image extension.',
-    'in'                   => 'The selected :attribute is invalid.',
-    'integer'              => 'The :attribute must be an integer.',
-    'ip'                   => 'The :attribute must be a valid IP address.',
-    'ipv4'                 => 'The :attribute must be a valid IPv4 address.',
-    'ipv6'                 => 'The :attribute must be a valid IPv6 address.',
-    'json'                 => 'The :attribute must be a valid JSON string.',
+    'exists'               => ':attribute انتخاب شده، معتبر نیست.',
+    'image'                => ':attribute باید یک تصویر معتبر باشد.',
+    'image_extension'      => ':attribute باید یک تصویر با فرمت معتبر باشد.',
+    'in'                   => ':attribute انتخاب شده، معتبر نیست.',
+    'integer'              => ':attribute باید عدد صحیح باشد.',
+    'ip'                   => ':attribute باید آدرس IP معتبر باشد.',
+    'ipv4'                 => ':attribute باید یک آدرس معتبر از نوع IPv4 باشد.',
+    'ipv6'                 => ':attribute باید یک آدرس معتبر از نوع IPv6 باشد.',
+    'json'                 => 'فیلد :attribute باید یک رشته از نوع JSON باشد.',
     'lt'                   => [
-        'numeric' => 'The :attribute must be less than :value.',
-        'file'    => 'The :attribute must be less than :value kilobytes.',
-        'string'  => 'The :attribute must be less than :value characters.',
-        'array'   => 'The :attribute must have less than :value items.',
+        'numeric' => ':attribute باید کوچکتر از :value باشد.',
+        'file'    => ':attribute باید کوچکتر از :value کیلوبایت باشد.',
+        'string'  => ':attribute باید کمتر از :value کاراکتر داشته باشد.',
+        'array'   => ':attribute باید کمتر از :value آیتم داشته باشد.',
     ],
     'lte'                  => [
-        'numeric' => 'The :attribute must be less than or equal :value.',
-        'file'    => 'The :attribute must be less than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be less than or equal :value characters.',
-        'array'   => 'The :attribute must not have more than :value items.',
+        'numeric' => ':attribute باید کوچکتر یا مساوی :value باشد.',
+        'file'    => ':attribute باید کوچکتر یا مساوی :value کیلوبایت باشد.',
+        'string'  => ':attribute باید کمتر یا مساوی :value کاراکتر داشته باشد.',
+        'array'   => ':attribute باید کمتر یا مساوی :value آیتم داشته باشد.',
     ],
     'max'                  => [
-        'numeric' => 'The :attribute may not be greater than :max.',
-        'file'    => 'The :attribute may not be greater than :max kilobytes.',
-        'string'  => 'The :attribute may not be greater than :max characters.',
-        'array'   => 'The :attribute may not have more than :max items.',
+        'numeric' => ':attribute نباید بزرگتر از :max باشد.',
+        'file'    => ':attribute نباید بزرگتر از :max کیلوبایت باشد.',
+        'string'  => ':attribute نباید بیشتر از :max کاراکتر داشته باشد.',
+        'array'   => ':attribute نباید بیشتر از :max آیتم داشته باشد.',
     ],
-    'mimes'                => 'The :attribute must be a file of type: :values.',
+    'mimes'                => 'فرمت‌های معتبر فایل عبارتند از: :values.',
     'min'                  => [
-        'numeric' => 'The :attribute must be at least :min.',
-        'file'    => 'The :attribute must be at least :min kilobytes.',
-        'string'  => 'The :attribute must be at least :min characters.',
-        'array'   => 'The :attribute must have at least :min items.',
+        'numeric' => ':attribute نباید کوچکتر از :min باشد.',
+        'file'    => ':attribute نباید کوچکتر از :min کیلوبایت باشد.',
+        'string'  => ':attribute نباید کمتر از :min کاراکتر داشته باشد.',
+        'array'   => ':attribute نباید کمتر از :min آیتم داشته باشد.',
     ],
-    'not_in'               => 'The selected :attribute is invalid.',
-    'not_regex'            => 'The :attribute format is invalid.',
-    'numeric'              => 'The :attribute must be a number.',
-    'regex'                => 'The :attribute format is invalid.',
-    'required'             => 'The :attribute field is required.',
-    'required_if'          => 'The :attribute field is required when :other is :value.',
-    'required_with'        => 'The :attribute field is required when :values is present.',
-    'required_with_all'    => 'The :attribute field is required when :values is present.',
-    'required_without'     => 'The :attribute field is required when :values is not present.',
-    'required_without_all' => 'The :attribute field is required when none of :values are present.',
-    'same'                 => 'The :attribute and :other must match.',
-    'safe_url'             => 'The provided link may not be safe.',
+    'not_in'               => ':attribute انتخاب شده، معتبر نیست.',
+    'not_regex'            => 'فرمت :attribute معتبر نیست.',
+    'numeric'              => ':attribute باید عدد یا رشته‌ای از اعداد باشد.',
+    'regex'                => 'فرمت :attribute معتبر نیست.',
+    'required'             => 'فیلد :attribute الزامی است.',
+    'required_if'          => 'هنگامی که :other برابر با :value است، فیلد :attribute الزامی است.',
+    'required_with'        => 'در صورت وجود فیلد :values، فیلد :attribute نیز الزامی است.',
+    'required_with_all'    => 'در صورت وجود فیلدهای :values، فیلد :attribute نیز الزامی است.',
+    'required_without'     => 'در صورت عدم وجود فیلد :values، فیلد :attribute الزامی است.',
+    'required_without_all' => 'در صورت عدم وجود هر یک از فیلدهای :values، فیلد :attribute الزامی است.',
+    'same'                 => ':attribute و :other باید همانند هم باشند.',
+    'safe_url'             => ':attribute معتبر نمی‌باشد.',
     'size'                 => [
-        'numeric' => 'The :attribute must be :size.',
-        'file'    => 'The :attribute must be :size kilobytes.',
-        'string'  => 'The :attribute must be :size characters.',
-        'array'   => 'The :attribute must contain :size items.',
+        'numeric' => ':attribute باید برابر با :size باشد.',
+        'file'    => ':attribute باید برابر با :size کیلوبایت باشد.',
+        'string'  => ':attribute باید برابر با :size کاراکتر باشد.',
+        'array'   => ':attribute باید شامل :size آیتم باشد.',
     ],
-    'string'               => 'The :attribute must be a string.',
-    'timezone'             => 'The :attribute must be a valid zone.',
-    'unique'               => 'The :attribute has already been taken.',
-    'url'                  => 'The :attribute format is invalid.',
-    'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
+    'string'               => 'فیلد :attribute باید متن باشد.',
+    'timezone'             => 'فیلد :attribute باید یک منطقه زمانی معتبر باشد.',
+    'totp'                 => 'کد ارائه شده معتبر نیست یا منقضی شده است.',
+    'unique'               => ':attribute قبلا انتخاب شده است.',
+    'url'                  => ':attribute معتبر نمی‌باشد.',
+    'uploaded'             => 'بارگذاری فایل :attribute موفقیت آمیز نبود.',
 
     // Custom validation lines
     'custom' => [
         'password-confirm' => [
-            'required_with' => 'Password confirmation required',
+            'required_with' => 'تایید کلمه عبور اجباری می باشد',
         ],
     ],
 
index 8b62bd0528a390770f9e1fe6b9694ef88867f317..8f57cd500d11b33b46a020ea778461b4eab19ce4 100644 (file)
@@ -26,14 +26,14 @@ return [
     'chapter_move'                => 'a déplacé le chapitre',
 
     // Books
-    'book_create'                 => 'a créé le livre',
+    'book_create'                 => 'a créé un livre',
     'book_create_notification'    => 'Livre créé avec succès',
     'book_update'                 => 'a modifié le livre',
     'book_update_notification'    => 'Livre modifié avec succès',
-    'book_delete'                 => 'a supprimé le livre',
+    'book_delete'                 => 'a supprimé un livre',
     'book_delete_notification'    => 'Livre supprimé avec succès',
     'book_sort'                   => 'a réordonné le livre',
-    'book_sort_notification'      => 'Livre réordonné avec succès',
+    'book_sort_notification'      => 'Livre restauré avec succès',
 
     // Bookshelves
     'bookshelf_create'            => 'a créé l\'étagère',
@@ -43,7 +43,23 @@ return [
     'bookshelf_delete'                 => 'a supprimé l\'étagère',
     'bookshelf_delete_notification'    => 'Étagère supprimée avec succès',
 
+    // Favourites
+    'favourite_add_notification' => '":name" a été ajouté dans vos favoris',
+    'favourite_remove_notification' => '":name" a été supprimé de vos favoris',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Méthode multi-facteurs configurée avec succès',
+    'mfa_remove_method_notification' => 'Méthode multi-facteurs supprimée avec succès',
+
+    // Webhooks
+    'webhook_create' => 'Créer un Webhook',
+    'webhook_create_notification' => 'Webhook créé avec succès',
+    'webhook_update' => 'éditer un Webhook',
+    'webhook_update_notification' => 'Webhook modifié avec succès',
+    'webhook_delete' => 'supprimer un Webhook',
+    'webhook_delete_notification' => 'Webhook supprimé avec succès',
+
     // Other
     'commented_on'                => 'a commenté',
-    'permissions_update'          => 'mettre à jour les autorisations',
+    'permissions_update'          => 'a mis à jour les autorisations sur',
 ];
index 07252420a030a8b87fc3e1b3eb9de8e41c389f78..0057948eb0e9be6b8a1d1fe7588565c82bfd8b9c 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Mot de passe',
     'password_confirm' => 'Confirmez le mot de passe',
-    'password_hint' => 'Doit faire plus de 7 caractères',
+    'password_hint' => 'Doit être d\'au moins 8 caractères',
     'forgot_password' => 'Mot de passe oublié ?',
     'remember_me' => 'Se souvenir de moi',
     'ldap_email_hint' => 'Merci d\'entrer une adresse e-mail pour ce compte.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Cette adresse e-mail ne peut pas accéder à l\'application',
     'register_success' => 'Merci pour votre inscription. Vous êtes maintenant inscrit(e) et connecté(e)',
 
-
     // Password Reset
     'reset_password' => 'Réinitialiser le mot de passe',
     'reset_password_send_instructions' => 'Entrez votre adresse e-mail ci-dessous et un e-mail avec un lien de réinitialisation de mot de passe vous sera envoyé.',
@@ -49,21 +48,20 @@ return [
     'email_reset_text' => 'Vous recevez cet e-mail parce que nous avons reçu une demande de réinitialisation pour votre compte.',
     'email_reset_not_requested' => 'Si vous n\'avez pas effectué cette demande, vous pouvez ignorer cet e-mail.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirmez votre adresse e-mail pour :appName',
     'email_confirm_greeting' => 'Merci d\'avoir rejoint :appName !',
     'email_confirm_text' => 'Merci de confirmer en cliquant sur le lien ci-dessous :',
     'email_confirm_action' => 'Confirmez votre adresse e-mail',
     'email_confirm_send_error' => 'La confirmation par e-mail est requise mais le système n\'a pas pu envoyer l\'e-mail. Contactez l\'administrateur système.',
-    'email_confirm_success' => 'Votre adresse e-mail a été confirmée !',
-    'email_confirm_resent' => 'L\'e-mail de confirmation a été ré-envoyé. Vérifiez votre boîte de récéption.',
+    'email_confirm_success' => 'Votre adresse e-mail a été confirmée ! Vous devriez maintenant pouvoir vous connecter en utilisant cette adresse e-mail.',
+    'email_confirm_resent' => 'L\'e-mail de confirmation a été ré-envoyé. Vérifiez votre boîte de réception.',
 
     'email_not_confirmed' => 'Adresse e-mail non confirmée',
     'email_not_confirmed_text' => 'Votre adresse e-mail n\'a pas été confirmée.',
     'email_not_confirmed_click_link' => 'Merci de cliquer sur le lien dans l\'e-mail qui vous a été envoyé après l\'enregistrement.',
     'email_not_confirmed_resend' => 'Si vous ne retrouvez plus l\'e-mail, vous pouvez renvoyer un e-mail de confirmation en utilisant le formulaire ci-dessous.',
-    'email_not_confirmed_resend_button' => 'Renvoyez l\'e-mail de confirmation',
+    'email_not_confirmed_resend_button' => 'Renvoyer l\'e-mail de confirmation',
 
     // User Invite
     'user_invite_email_subject' => 'Vous avez été invité(e) à rejoindre :appName !',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Bienvenue dans :appName !',
     'user_invite_page_text' => 'Pour finaliser votre compte et recevoir l\'accès, vous devez renseigner le mot de passe qui sera utilisé pour la connexion à :appName les prochaines fois.',
     'user_invite_page_confirm_button' => 'Confirmez le mot de passe',
-    'user_invite_success' => 'Mot de passe renseigné, vous avez maintenant accès à :appName !'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Mot de passe défini, vous devriez maintenant pouvoir vous connecter en utilisant votre mot de passe défini pour accéder à :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Authentification multi-facteurs',
+    'mfa_setup_desc' => 'Configurer l\'authentification multi-facteurs ajoute une couche supplémentaire de sécurité à votre compte utilisateur.',
+    'mfa_setup_configured' => 'Déjà configuré',
+    'mfa_setup_reconfigure' => 'Reconfigurer',
+    'mfa_setup_remove_confirmation' => 'Êtes-vous sûr de vouloir supprimer cette méthode d\'authentification multi-facteurs ?',
+    'mfa_setup_action' => 'Configuration',
+    'mfa_backup_codes_usage_limit_warning' => 'Il vous reste moins de 5 codes de secours, veuillez générer et stocker un nouveau jeu de codes afin d\'éviter tout verrouillage de votre compte.',
+    'mfa_option_totp_title' => 'Application mobile',
+    'mfa_option_totp_desc' => 'Pour utiliser l\'authentification multi-facteurs, vous aurez besoin d\'une application mobile qui supporte TOTP comme Google Authenticator, Authy ou Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Codes de secours',
+    'mfa_option_backup_codes_desc' => 'Stockez en toute sécurité un jeu de codes de secours que vous pourrez utiliser pour vérifier votre identité.',
+    'mfa_gen_confirm_and_enable' => 'Confirmer et activer',
+    'mfa_gen_backup_codes_title' => 'Configuration des codes de secours',
+    'mfa_gen_backup_codes_desc' => 'Stockez la liste des codes ci-dessous dans un endroit sûr. Lorsque vous accédez au système, vous pourrez utiliser l\'un des codes comme un deuxième mécanisme d\'authentification.',
+    'mfa_gen_backup_codes_download' => 'Télécharger les codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Chaque code ne peut être utilisé qu\'une seule fois',
+    'mfa_gen_totp_title' => 'Configuration de l\'application mobile',
+    'mfa_gen_totp_desc' => 'Pour utiliser l\'authentification multi-facteurs, vous aurez besoin d\'une application mobile qui supporte TOTP comme Google Authenticator, Authy ou Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scannez le QR code ci-dessous avec votre application d\'authentification préférée pour débuter.',
+    'mfa_gen_totp_verify_setup' => 'Vérifier la configuration',
+    'mfa_gen_totp_verify_setup_desc' => 'Vérifiez que tout fonctionne en utilisant un code généré par votre application d\'authentification, dans la zone ci-dessous :',
+    'mfa_gen_totp_provide_code_here' => 'Fournissez le code généré par votre application ici',
+    'mfa_verify_access' => 'Vérifier l\'accès',
+    'mfa_verify_access_desc' => 'Votre compte d\'utilisateur vous demande de confirmer votre identité par un niveau supplémentaire de vérification avant que vous n\'ayez accès. Vérifiez-la en utilisant l\'une de vos méthodes configurées pour continuer.',
+    'mfa_verify_no_methods' => 'Aucune méthode configurée',
+    'mfa_verify_no_methods_desc' => 'Aucune méthode d\'authentification multi-facteurs n\'a pu être trouvée pour votre compte. Vous devez configurer au moins une méthode avant d\'obtenir l\'accès.',
+    'mfa_verify_use_totp' => 'Vérifier à l\'aide d\'une application mobile',
+    'mfa_verify_use_backup_codes' => 'Vérifier en utilisant un code de secours',
+    'mfa_verify_backup_code' => 'Code de secours',
+    'mfa_verify_backup_code_desc' => 'Entrez l\'un de vos codes de secours restants ci-dessous :',
+    'mfa_verify_backup_code_enter_here' => 'Saisissez un code de secours ici',
+    'mfa_verify_totp_desc' => 'Entrez ci-dessous le code généré à l\'aide de votre application mobile :',
+    'mfa_setup_login_notification' => 'Méthode multi-facteurs configurée. Veuillez maintenant vous reconnecter en utilisant la méthode configurée.',
+];
index 08c37ca0217c57007d8a59df7c397b571a8794f3..c373f747b11a1980491e4fd22d94278e88a6ec6e 100644 (file)
@@ -20,26 +20,33 @@ return [
     'role' => 'Rôle',
     'cover_image' => 'Image de couverture',
     'cover_image_description' => 'Cette image doit faire environ 440x250 px.',
-    
+
     // Actions
     'actions' => 'Actions',
     'view' => 'Voir',
     'view_all' => 'Tout afficher',
     'create' => 'Créer',
     'update' => 'Modifier',
-    'edit' => 'Editer',
+    'edit' => 'Éditer',
     'sort' => 'Trier',
     'move' => 'Déplacer',
     'copy' => 'Copier',
     'reply' => 'Répondre',
     'delete' => 'Supprimer',
     'delete_confirm' => 'Confirmer la suppression',
-    'search' => 'Chercher',
+    'search' => 'Rechercher',
     'search_clear' => 'Réinitialiser la recherche',
     'reset' => 'Réinitialiser',
     'remove' => 'Enlever',
     'add' => 'Ajouter',
+    'configure' => 'Configurer',
     'fullscreen' => 'Plein écran',
+    'favourite' => 'Favoris',
+    'unfavourite' => 'Supprimer des favoris',
+    'next' => 'Suivant',
+    'previous' => 'Précédent',
+    'filter_active' => 'Filtre actif :',
+    'filter_clear' => 'Effacer le filtre',
 
     // Sort Options
     'sort_options' => 'Options de tri',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Aucune activité',
     'no_items' => 'Aucun élément',
     'back_to_top' => 'Retour en haut',
+    'skip_to_main_content' => 'Passer au contenu principal',
     'toggle_details' => 'Afficher les détails',
     'toggle_thumbnails' => 'Afficher les vignettes',
     'details' => 'Détails',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Vue en liste',
     'default' => 'Défaut',
     'breadcrumb' => 'Fil d\'Ariane',
+    'status' => 'Statut',
+    'status_active' => 'Actif',
+    'status_inactive' => 'Inactif',
+    'never' => 'Jamais',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Développer le menu',
     'profile_menu' => 'Menu du profil',
     'view_profile' => 'Voir le profil',
     'edit_profile' => 'Modifier le profil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informations',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Onglet : Afficher les informations secondaires',
     'tab_content' => 'Contenu',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Onglet : Afficher le contenu principal',
 
     // Email Content
     'email_action_help' => 'Si vous rencontrez des problèmes pour cliquer sur le bouton ":actionText", copiez et collez l\'adresse ci-dessous dans votre navigateur :',
index 6cce4f80418f1e5967daceee7b8e72b30b0f28a6..fed157a4793ff589ffc975026859b157c2062a96 100644 (file)
@@ -26,7 +26,7 @@ return [
     'image_upload_remove' => 'Supprimer',
 
     // Code Editor
-    'code_editor' => 'Editer le code',
+    'code_editor' => 'Éditer le code',
     'code_language' => 'Langage du code',
     'code_content' => 'Contenu du code',
     'code_session_history' => 'Historique de session',
index 56cf6065b91c2c2d8c290d2859484ea7752b5c1c..d1fd92597244dea1405134b67b7efae4cb7669c5 100644 (file)
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => 'Créé :timeLength par :user',
     'meta_updated' => 'Mis à jour :timeLength',
     'meta_updated_name' => 'Mis à jour :timeLength par :user',
-    'meta_owned_name' => 'Possédé par :user',
+    'meta_owned_name' => 'Appartient à :user',
     'entity_select' => 'Sélectionner l\'entité',
     'images' => 'Images',
     'my_recent_drafts' => 'Mes brouillons récents',
     'my_recently_viewed' => 'Vus récemment',
+    'my_most_viewed_favourites' => 'Mes favoris les plus vus',
+    'my_favourites' => 'Mes favoris',
     'no_pages_viewed' => 'Vous n\'avez rien visité récemment',
     'no_pages_recently_created' => 'Aucune page créée récemment',
     'no_pages_recently_updated' => 'Aucune page mise à jour récemment',
@@ -34,10 +36,11 @@ return [
     'export_html' => 'Fichiers web',
     'export_pdf' => 'Fichier PDF',
     'export_text' => 'Document texte',
+    'export_md' => 'Fichiers Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Autorisations',
-    'permissions_intro' => 'Une fois activées ces permissions prendront la priorité sur tous les sets de permissions préexistants.',
+    'permissions_intro' => 'Une fois activées, ces permissions auront la priorité sur tous les jeux de permissions préexistants.',
     'permissions_enable' => 'Activer les permissions personnalisées',
     'permissions_save' => 'Enregistrer les permissions',
     'permissions_owner' => 'Propriétaire',
@@ -77,8 +80,8 @@ return [
     'shelves_empty' => 'Aucune étagère n\'a été créée',
     'shelves_create' => 'Créer une nouvelle étagère',
     'shelves_popular' => 'Étagères populaires',
-    'shelves_new' => 'Nouvelles Ã\89tagères',
-    'shelves_new_action' => 'Nouvelle Ã\89tagère',
+    'shelves_new' => 'Nouvelles Ã©tagères',
+    'shelves_new_action' => 'Nouvelle Ã©tagère',
     'shelves_popular_empty' => 'Les étagères les plus populaires apparaîtront ici.',
     'shelves_new_empty' => 'Les étagères les plus récentes apparaitront ici.',
     'shelves_save' => 'Enregistrer l\'étagère',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permissions de l\'étagère',
     'shelves_permissions_updated' => 'Permissions de l\'étagère mises à jour',
     'shelves_permissions_active' => 'Permissions de l\'étagère activées',
+    'shelves_permissions_cascade_warning' => 'Les permissions sur les étagères ne sont pas automatiquement recopiées aux livres qu\'elles contiennent, car un livre peut exister dans plusieurs étagères. Les permissions peuvent cependant être recopiées vers les livres contenus en utilisant l\'option ci-dessous.',
     'shelves_copy_permissions_to_books' => 'Copier les permissions vers les livres',
     'shelves_copy_permissions' => 'Copier les permissions',
     'shelves_copy_permissions_explain' => 'Ceci va appliquer les permissions actuelles de cette étagère à tous les livres qu\'elle contient. Avant de  continuer, assurez-vous que toutes les permissions de cette étagère ont été sauvegardées.',
@@ -128,7 +132,7 @@ return [
     'books_empty_sort_current_book' => 'Trier les pages du livre',
     'books_empty_add_chapter' => 'Ajouter un chapitre',
     'books_permissions_active' => 'Permissions personnalisées activées',
-    'books_search_this' => 'Chercher dans le livre',
+    'books_search_this' => 'Rechercher dans ce livre',
     'books_navigation' => 'Navigation dans le livre',
     'books_sort' => 'Trier les contenus du livre',
     'books_sort_named' => 'Trier le livre :bookName',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Les chapitres en dernier',
     'books_sort_show_other' => 'Afficher d\'autres livres',
     'books_sort_save' => 'Enregistrer l\'ordre',
+    'books_copy' => 'Copier le livre',
+    'books_copy_success' => 'Livre copié avec succès',
 
     // Chapters
     'chapter' => 'Chapitre',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Déplacer le chapitre',
     'chapters_move_named' => 'Déplacer le chapitre :chapterName',
     'chapter_move_success' => 'Chapitre déplacé dans :bookName',
+    'chapters_copy' => 'Copier le chapitre',
+    'chapters_copy_success' => 'Chapitre copié avec succès',
     'chapters_permissions' => 'Permissions du chapitre',
     'chapters_empty' => 'Il n\'y a pas de page dans ce chapitre actuellement.',
     'chapters_permissions_active' => 'Permissions du chapitre activées',
@@ -170,7 +178,7 @@ return [
     'pages_popular' => 'Pages populaires',
     'pages_new' => 'Nouvelle page',
     'pages_attachments' => 'Fichiers joints',
-    'pages_navigation' => 'Navigation des pages',
+    'pages_navigation' => 'Navigation dans la page',
     'pages_delete' => 'Supprimer la page',
     'pages_delete_named' => 'Supprimer la page :pageName',
     'pages_delete_draft_named' => 'supprimer le brouillon de la page :pageName',
@@ -185,16 +193,16 @@ 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 sauvé le ',
+    'pages_edit_draft_save_at' => 'Brouillon enregistré le ',
     'pages_edit_delete_draft' => 'Supprimer le brouillon',
-    'pages_edit_discard_draft' => 'Ecarter le brouillon',
+    'pages_edit_discard_draft' => 'Jeter le brouillon',
     'pages_edit_set_changelog' => 'Remplir le journal des changements',
     'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués',
-    'pages_edit_enter_changelog' => 'Entrer dans le journal des changements',
-    'pages_save' => 'Enregistrez la page',
+    'pages_edit_enter_changelog' => 'Ouvrir le journal des changements',
+    'pages_save' => 'Enregistrer la page',
     'pages_title' => 'Titre de la page',
     'pages_name' => 'Nom de la page',
-    'pages_md_editor' => 'Editeur',
+    'pages_md_editor' => 'Éditeur',
     'pages_md_preview' => 'Prévisualisation',
     'pages_md_insert_image' => 'Insérer une image',
     'pages_md_insert_link' => 'Insérer un lien',
@@ -219,7 +227,7 @@ return [
     'pages_revisions_numbered_changes' => 'Modification #:id',
     'pages_revisions_changelog' => 'Journal des changements',
     'pages_revisions_changes' => 'Changements',
-    'pages_revisions_current' => 'Version courante',
+    'pages_revisions_current' => 'Version actuelle',
     'pages_revisions_preview' => 'Prévisualisation',
     'pages_revisions_restore' => 'Restaurer',
     'pages_revisions_none' => 'Cette page n\'a aucune révision',
@@ -228,8 +236,9 @@ return [
     'pages_permissions_active' => 'Permissions de page actives',
     'pages_initial_revision' => 'Publication initiale',
     'pages_initial_name' => 'Nouvelle page',
-    'pages_editing_draft_notification' => 'Vous éditez actuellement un brouillon qui a été sauvé :timeDiff.',
-    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visite. Vous devriez écarter ce brouillon.',
+    'pages_editing_draft_notification' => 'Vous éditez actuellement un brouillon qui a été enregistré :timeDiff.',
+    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visite. Vous devriez jeter ce brouillon.',
+    'pages_draft_page_changed_since_creation' => 'Cette page a été mise à jour depuis que ce brouillon a été créé. Il est recommandé de supprimer ce brouillon ou de veiller à ne pas écraser toute modification de page.',
     'pages_draft_edit_active' => [
         'start_a' => ':count utilisateurs ont commencé à éditer cette page',
         'start_b' => ':userName a commencé à éditer cette page',
@@ -238,7 +247,7 @@ return [
         'message' => ':start :time. Attention à ne pas écraser les mises à jour de quelqu\'un d\'autre !',
     ],
     'pages_draft_discarded' => 'Brouillon écarté, la page est dans sa version actuelle.',
-    'pages_specific' => 'Page Spécifique',
+    'pages_specific' => 'Page spécifique',
     'pages_is_template' => 'Modèle de page',
 
     // Editor Sidebar
@@ -249,10 +258,20 @@ return [
     'tag' => 'Mot-clé',
     'tags' =>  'Mots-clés',
     'tag_name' =>  'Nom du tag',
-    'tag_value' => 'Valeur du mot-clé (Optionnel)',
+    'tag_value' => 'Valeur du mot-clé (optionnel)',
     'tags_explain' => "Ajouter des mots-clés pour catégoriser votre contenu.",
     'tags_add' => 'Ajouter un autre mot-clé',
-    'tags_remove' => 'Supprimer le tag',
+    'tags_remove' => 'Supprimer le mot-clé',
+    'tags_usages' => 'Total des utilisations des tags',
+    'tags_assigned_pages' => 'Attribuer aux pages',
+    'tags_assigned_chapters' => 'Attribuer aux chapitres',
+    'tags_assigned_books' => 'Attribuer aux livres',
+    'tags_assigned_shelves' => 'Attribuer aux étagères',
+    'tags_x_unique_values' => ':count valeurs uniques',
+    'tags_all_values' => 'Toutes les valeurs',
+    'tags_view_tags' => 'Voir les tags',
+    'tags_view_existing_tags' => 'Voir les tags existants',
+    'tags_list_empty_hint' => 'Les tags peuvent être assignés via la barre latérale de l\'éditeur de page ou lors de l\'édition des détails d\'un livre, d\'un chapitre ou d\'une étagère.',
     'attachments' => 'Fichiers joints',
     'attachments_explain' => 'Ajouter des fichiers ou des liens pour les afficher sur votre page. Ils seront affichés dans la barre latérale',
     'attachments_explain_instant_save' => 'Ces changements sont enregistrés immédiatement.',
@@ -263,13 +282,13 @@ return [
     'attachments_delete' => 'Êtes-vous sûr de vouloir supprimer la pièce jointe ?',
     'attachments_dropzone' => 'Glissez des fichiers ou cliquez ici pour attacher des fichiers',
     'attachments_no_files' => 'Aucun fichier ajouté',
-    'attachments_explain_link' => 'Vous pouvez attacher un lien si vous ne souhaitez pas uploader un fichier.',
+    'attachments_explain_link' => 'Vous pouvez ajouter un lien si vous ne souhaitez pas uploader un fichier.',
     'attachments_link_name' => 'Nom du lien',
     'attachment_link' => 'Lien de l\'attachement',
     'attachments_link_url' => 'Lien sur un fichier',
     'attachments_link_url_hint' => 'URL du site ou du fichier',
-    'attach' => 'Attacher',
-    'attachments_insert_link' => 'Ajouter un lien de pièce jointe à la page',
+    'attach' => 'Ajouter',
+    'attachments_insert_link' => 'Ajouter un lien à la page',
     'attachments_edit_file' => 'Modifier le fichier',
     'attachments_edit_file_name' => 'Nom du fichier',
     'attachments_edit_drop_upload' => 'Glissez un fichier ou cliquer pour mettre à jour le fichier',
@@ -284,7 +303,7 @@ return [
     'templates_explain_set_as_template' => 'Vous pouvez définir cette page comme modèle pour que son contenu soit utilisé lors de la création d\'autres pages. Les autres utilisateurs pourront utiliser ce modèle s\'ils ont les permissions pour cette page.',
     'templates_replace_content' => 'Remplacer le contenu de la page',
     'templates_append_content' => 'Ajouter après le contenu de la page',
-    'templates_prepend_content' => 'Ajouter devant le contenu de la page',
+    'templates_prepend_content' => 'Ajouter avant le contenu de la page',
 
     // Profile View
     'profile_user_for_x' => 'Utilisateur depuis :time',
@@ -309,12 +328,20 @@ return [
     'comment_deleted_success' => 'Commentaire supprimé',
     'comment_created_success' => 'Commentaire ajouté',
     'comment_updated_success' => 'Commentaire mis à jour',
-    'comment_delete_confirm' => 'Etes-vous sûr de vouloir supprimer ce commentaire ?',
+    'comment_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer ce commentaire ?',
     'comment_in_reply_to' => 'En réponse à :commentId',
 
     // Revision
     'revision_delete_confirm' => 'Êtes-vous sûr de vouloir supprimer cette révision ?',
     'revision_restore_confirm' => 'Êtes-vous sûr de vouloir restaurer cette révision ? Le contenu courant de la page va être remplacé.',
     'revision_delete_success' => 'Révision supprimée',
-    'revision_cannot_delete_latest' => 'Impossible de supprimer la dernière révision.'
+    'revision_cannot_delete_latest' => 'Impossible de supprimer la dernière révision.',
+
+    // Copy view
+    'copy_consider' => 'Veuillez prendre en compte ce qui suit lors de la copie du contenu.',
+    'copy_consider_permissions' => 'Les paramètres de permission personnalisés ne seront pas copiés.',
+    'copy_consider_owner' => 'Vous deviendrez le propriétaire de tout le contenu copié.',
+    'copy_consider_images' => 'Les fichiers image de la page ne seront pas dupliqués et les images originales conserveront leur relation avec la page vers laquelle elles ont été initialement téléchargées.',
+    'copy_consider_attachments' => 'Les pièces jointes de la page ne seront pas copiées.',
+    'copy_consider_access' => 'Un changement d\'emplacement, de propriétaire ou d\'autorisation peut rendre ce contenu accessible à ceux précédemment sans accès.',
 ];
index c1c14fe31f36ed8313026824792f19e2577cda8f..41cf1e148a3dec20350ef0906d3f3f0401060d32 100644 (file)
@@ -16,24 +16,28 @@ return [
     'email_confirmation_awaiting' => 'L\'adresse e-mail du compte utilisé doit être confirmée',
     'ldap_fail_anonymous' => 'L\'accès LDAP anonyme n\'a pas abouti',
     'ldap_fail_authed' => 'L\'accès LDAP n\'a pas abouti avec cet utilisateur et ce mot de passe',
-    'ldap_extension_not_installed' => 'L\'extension LDAP PHP n\'est pas installée',
+    'ldap_extension_not_installed' => 'L\'extension PHP LDAP n\'est pas installée',
     'ldap_cannot_connect' => 'Impossible de se connecter au serveur LDAP, la connexion initiale a échoué',
     'saml_already_logged_in' => 'Déjà connecté',
     'saml_user_not_registered' => 'L\'utilisateur :name n\'est pas enregistré et l\'enregistrement automatique est désactivé',
     'saml_no_email_address' => 'Impossible de trouver une adresse e-mail, pour cet utilisateur, dans les données fournies par le système d\'authentification externe',
     'saml_invalid_response_id' => 'La requête du système d\'authentification externe n\'est pas reconnue par un processus démarré par cette application. Naviguer après une connexion peut causer ce problème.',
-    'saml_fail_authed' => 'Connexion avec :system échoue, le système n\'a pas fourni l\'autorisation réussie',
+    'saml_fail_authed' => 'Connexion avec :system échouée, le système n\'a pas fourni l\'autorisation réussie',
+    'oidc_already_logged_in' => 'Déjà connecté',
+    'oidc_user_not_registered' => 'L\'utilisateur :name n\'est pas enregistré et l\'enregistrement automatique est désactivé',
+    'oidc_no_email_address' => 'Impossible de trouver une adresse e-mail pour cet utilisateur, dans les données fournies par le système d\'authentification externe',
+    'oidc_fail_authed' => 'La connexion en utilisant :system a échoué, le système n\'a pas fourni d\'autorisation avec succès',
     'social_no_action_defined' => 'Pas d\'action définie',
     'social_login_bad_response' => "Erreur pendant la tentative de connexion à :socialAccount : \n:error",
     'social_account_in_use' => 'Ce compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
-    'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
+    'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le rattacher à votre profil existant.',
     'social_account_existing' => 'Ce compte :socialAccount est déjà rattaché à votre profil.',
     'social_account_already_used_existing' => 'Ce compte :socialAccount est déjà utilisé par un autre utilisateur.',
     'social_account_not_used' => 'Ce compte :socialAccount n\'est lié à aucun utilisateur. ',
-    'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez le lier avec l\'option :socialAccount.',
-    'social_driver_not_found' => 'Pilote de compte social absent',
+    'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez en créer un avec l\'option :socialAccount.',
+    'social_driver_not_found' => 'Pilote de compte de réseaux sociaux absent',
     'social_driver_not_configured' => 'Vos préférences pour le compte :socialAccount sont incorrectes.',
-    'invite_token_expired' => 'Le lien de cette invitation a expiré. Vous pouvez essayer de réinitiliser votre mot de passe.',
+    'invite_token_expired' => 'Le lien de cette invitation a expiré. Vous pouvez essayer de réinitialiser votre mot de passe.',
 
     // System
     'path_not_writable' => 'Impossible d\'écrire dans :filePath. Assurez-vous d\'avoir les droits d\'écriture sur le serveur',
@@ -42,14 +46,14 @@ return [
     'server_upload_limit' => 'La taille du fichier est trop grande.',
     'uploaded'  => 'Le serveur n\'autorise pas l\'envoi d\'un fichier de cette taille. Veuillez essayer avec une taille de fichier réduite.',
     'image_upload_error' => 'Une erreur est survenue pendant l\'envoi de l\'image',
-    'image_upload_type_error' => 'LE format de l\'image envoyée n\'est pas valide',
+    'image_upload_type_error' => 'Le format de l\'image envoyée n\'est pas valide',
     'file_upload_timeout' => 'Le téléchargement du fichier a expiré.',
 
     // Attachments
     'attachment_not_found' => 'Fichier joint non trouvé',
 
     // Pages
-    'page_draft_autosave_fail' => 'Le brouillon n\'a pas pu être sauvé. Vérifiez votre connexion internet',
+    'page_draft_autosave_fail' => 'Le brouillon n\'a pas pu être enregistré. Vérifiez votre connexion internet',
     'page_custom_home_deletion' => 'Impossible de supprimer une page définie comme page d\'accueil',
 
     // Entities
@@ -60,10 +64,10 @@ return [
     'chapter_not_found' => 'Chapitre non trouvé',
     'selected_book_not_found' => 'Ce livre n\'a pas été trouvé',
     'selected_book_chapter_not_found' => 'Ce livre ou chapitre n\'a pas été trouvé',
-    'guests_cannot_save_drafts' => 'Les invités ne peuvent pas sauver de brouillons',
+    'guests_cannot_save_drafts' => 'Les invités ne peuvent pas enregistrer de brouillons',
 
     // Users
-    'users_cannot_delete_only_admin' => 'Vous ne pouvez pas supprimer le dernier admin',
+    'users_cannot_delete_only_admin' => 'Vous ne pouvez pas supprimer le dernier administrateur',
     'users_cannot_delete_guest' => 'Vous ne pouvez pas supprimer l\'utilisateur invité',
 
     // Roles
@@ -74,7 +78,7 @@ return [
 
     // Comments
     'comment_list' => 'Une erreur s\'est produite lors de la récupération des commentaires.',
-    'cannot_add_comment_to_draft' => 'Vous ne pouvez pas ajouter de commentaires à un projet.',
+    'cannot_add_comment_to_draft' => 'Vous ne pouvez pas ajouter de commentaires à un brouillon.',
     'comment_add' => 'Une erreur s\'est produite lors de l\'ajout du commentaire.',
     'comment_delete' => 'Une erreur s\'est produite lors de la suppression du commentaire.',
     'empty_comment' => 'Impossible d\'ajouter un commentaire vide.',
@@ -82,7 +86,10 @@ return [
     // Error pages
     '404_page_not_found' => 'Page non trouvée',
     'sorry_page_not_found' => 'Désolé, cette page n\'a pas pu être trouvée.',
-    'sorry_page_not_found_permission_warning' => 'Si vous vous attendiez à ce que cette page existe, il se peut que vous n\'ayez pas l\'autorisation de la consulter.',
+    'sorry_page_not_found_permission_warning' => 'Si cette page est censée exister, il se peut que vous n\'ayez pas l\'autorisation de la consulter.',
+    'image_not_found' => 'Image non trouvée',
+    'image_not_found_subtitle' => 'Désolé, l\'image que vous cherchez ne peut être trouvée.',
+    'image_not_found_details' => 'Si cette image était censée exister, il se pourrait qu\'elle ait été supprimée.',
     'return_home' => 'Retour à l\'accueil',
     'error_occurred' => 'Une erreur est survenue',
     'app_down' => ':appName n\'est pas en service pour le moment',
@@ -93,7 +100,7 @@ return [
     'api_bad_authorization_format' => 'Un jeton d\'autorisation a été trouvé pour la requête, mais le format semble incorrect',
     'api_user_token_not_found' => 'Aucun jeton API correspondant n\'a été trouvé pour le jeton d\'autorisation fourni',
     'api_incorrect_token_secret' => 'Le secret fourni pour le jeton d\'API utilisé est incorrect',
-    'api_user_no_api_permission' => 'Le propriétaire du jeton API utilisé n\'a pas la permission de passer des appels API',
+    'api_user_no_api_permission' => 'Le propriétaire du jeton API utilisé n\'a pas la permission de passer des requêtes API',
     'api_user_token_expired' => 'Le jeton d\'autorisation utilisé a expiré',
 
     // Settings & Maintenance
index b0ff20e282989ec735bf278ba97baaf6d1477b2f..e209c21782343ffb746a66c60aad00b0d0c592bf 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => 'Les mots de passe doivent faire au moins 6 caractères et correspondre à la confirmation.',
+    'password' => 'Les mots de passe doivent faire au moins 8 caractères et correspondre à la confirmation.',
     'user' => "Nous n'avons pas trouvé d'utilisateur avec cette adresse.",
     'token' => 'Le mot de passe reset du token n\'est pas valide pour cette adresse e-mail.',
-    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe !',
+    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe par e-mail !',
     'reset' => 'Votre mot de passe a été réinitialisé !',
 
 ];
index fb525cec28553bfa04cc836e48971739669b3dc0..a4d23855283d64cfeb0a4e6b2c1b6433b7930d17 100644 (file)
@@ -21,16 +21,16 @@ return [
     'app_public_access_desc' => 'L\'activation de cette option permettra aux visiteurs, qui ne sont pas connectés, d\'accéder au contenu de votre instance BookStack.',
     'app_public_access_desc_guest' => 'L\'accès pour les visiteurs publics peut être contrôlé par l\'utilisateur "Guest".',
     'app_public_access_toggle' => 'Autoriser l\'accès public',
-    'app_public_viewing' => 'Accepter le visionnage public des pages ?',
-    'app_secure_images' => 'Activer l\'ajout d\'image sécurisé ?',
+    'app_public_viewing' => 'Accepter l\'affichage public des pages ?',
+    'app_secure_images' => 'Ajout d\'image sécurisé',
     'app_secure_images_toggle' => 'Activer l\'ajout d\'image sécurisé',
     'app_secure_images_desc' => 'Pour des questions de performances, toutes les images sont publiques. Cette option ajoute une chaîne aléatoire difficile à deviner dans les URLs des images.',
-    'app_editor' => 'Editeur des pages',
+    'app_editor' => 'Éditeur des pages',
     'app_editor_desc' => 'Sélectionnez l\'éditeur qui sera utilisé pour modifier les pages.',
     'app_custom_html' => 'HTML personnalisé dans l\'en-tête',
     'app_custom_html_desc' => 'Le contenu inséré ici sera ajouté en bas de la balise <head> de toutes les pages. Vous pouvez l\'utiliser pour ajouter du CSS personnalisé ou un tracker analytique.',
-    'app_custom_html_disabled_notice' => 'Le contenu de la tête HTML personnalisée est désactivé sur cette page de paramètres pour garantir que les modifications les plus récentes peuvent être annulées.',
-    'app_logo' => 'Logo de l\'Application',
+    'app_custom_html_disabled_notice' => 'Le contenu de l\'en-tête HTML personnalisé est désactivé sur cette page de paramètres pour garantir que les modifications les plus récentes puissent être annulées.',
+    'app_logo' => 'Logo de l\'application',
     'app_logo_desc' => 'Cette image doit faire 43px de hauteur. <br>Les images plus larges seront réduites.',
     'app_primary_color' => 'Couleur principale de l\'application',
     'app_primary_color_desc' => 'Cela devrait être une valeur hexadécimale. <br>Laisser vide pour rétablir la couleur par défaut.',
@@ -38,7 +38,7 @@ return [
     'app_homepage_desc' => 'Choisissez une page à afficher sur la page d\'accueil au lieu de la vue par défaut. Les permissions sont ignorées pour les pages sélectionnées.',
     'app_homepage_select' => 'Choisissez une page',
     'app_footer_links' => 'Liens de pied de page',
-    'app_footer_links_desc' => 'Ajoutez des liens dans le pied de page du site. Ils seront affichés en bas de la plupart des pages, incluant celles qui ne nécesittent pas de connexion. Vous pouvez utiliser l\'étiquette "trans::<key>" pour utiliser les traductions définies par le système. Par exemple, utiliser "trans::common.privacy_policy" fournira la traduction de "Politique de Confidentalité" et "trans::common.terms_of_service" fournira la traduction de "Conditions d\'utilisation".',
+    'app_footer_links_desc' => 'Ajouter des liens à afficher dans le pied de page du site. Ils seront affichés en bas de la plupart des pages, y compris celles qui ne nécessitent pas de connexion. Vous pouvez utiliser une étiquette de "trans::<key>" pour utiliser les traductions définies par le système. Par exemple : utiliser "trans::common.privacy_policy" fournira le texte traduit "Privacy Policy" et "trans::common.terms_of_service" fournira le texte traduit "Terms of Service".',
     'app_footer_links_label' => 'Libellé du lien',
     'app_footer_links_url' => 'URL du lien',
     'app_footer_links_add' => 'Ajouter un lien en pied de page',
@@ -49,11 +49,11 @@ return [
     // Color settings
     'content_colors' => 'Couleur du contenu',
     'content_colors_desc' => 'Définit les couleurs pour tous les éléments de la hiérarchie d\'organisation des pages. Choisir les couleurs avec une luminosité similaire aux couleurs par défaut est recommandé pour la lisibilité.',
-    'bookshelf_color' => 'Couleur de l\'étagère',
-    'book_color' => 'Couleur du livre',
-    'chapter_color' => 'Couleur du chapitre',
-    'page_color' => 'Couleur de la page',
-    'page_draft_color' => 'Couleur du brouillon',
+    'bookshelf_color' => 'Couleur des étagères',
+    'book_color' => 'Couleur des livres',
+    'chapter_color' => 'Couleur des chapitres',
+    'page_color' => 'Couleur des pages',
+    'page_draft_color' => 'Couleur des brouillons',
 
     // Registration Settings
     'reg_settings' => 'Préférence pour l\'inscription',
@@ -72,19 +72,19 @@ return [
     // Maintenance settings
     'maint' => 'Maintenance',
     'maint_image_cleanup' => 'Nettoyer les images',
-    'maint_image_cleanup_desc' => "Scan le contenu des pages et des révisions pour vérifier les images et les dessins en cours d'utilisation et lesquels sont redondant. Veuillez à faire une sauvegarde de la base de données et des images avant de lancer ceci.",
+    'maint_image_cleanup_desc' => 'Scanne le contenu des pages et des révisions pour vérifier les images, les dessins en cours d\'utilisation et les doublons. Assurez-vous d\'avoir une sauvegarde de la base de données et des images avant de lancer ceci.',
     'maint_delete_images_only_in_revisions' => 'Supprimer également les images qui n\'existent que dans les anciennes révisions de page',
     'maint_image_cleanup_run' => 'Lancer le nettoyage',
-    'maint_image_cleanup_warning' => ':count images potentiellement inutilisées trouvées. Etes-vous sûr de vouloir supprimer ces images ?',
+    'maint_image_cleanup_warning' => ':count images potentiellement inutilisées trouvées. Êtes-vous sûr de vouloir supprimer ces images ?',
     'maint_image_cleanup_success' => ':count images potentiellement inutilisées trouvées et supprimées !',
     'maint_image_cleanup_nothing_found' => 'Aucune image inutilisée trouvée, rien à supprimer !',
-    'maint_send_test_email' => 'Envoyer un email de test',
+    'maint_send_test_email' => 'Envoyer un e-mail de test',
     'maint_send_test_email_desc' => 'Ceci envoie un e-mail de test à votre adresse e-mail spécifiée dans votre profil.',
-    'maint_send_test_email_run' => 'Envoyer un email de test',
-    'maint_send_test_email_success' => 'Email envoyé à :address',
-    'maint_send_test_email_mail_subject' => 'Email de test',
-    'maint_send_test_email_mail_greeting' => 'La livraison d\'email semble fonctionner !',
-    'maint_send_test_email_mail_text' => 'Félicitations ! Lorsque vous avez reçu cette notification par courriel, vos paramètres d\'email semblent être configurés correctement.',
+    'maint_send_test_email_run' => 'Envoyer un e-mail de test',
+    'maint_send_test_email_success' => 'E-mail envoyé à :address',
+    'maint_send_test_email_mail_subject' => 'E-mail de test',
+    'maint_send_test_email_mail_greeting' => 'L\'envoi d\'e-mail semble fonctionner !',
+    'maint_send_test_email_mail_text' => 'Félicitations ! Comme vous avez bien reçu cette notification, vos paramètres d\'e-mail semblent être configurés correctement.',
     'maint_recycle_bin_desc' => 'Les étagères, livres, chapitres et pages supprimés sont envoyés dans la corbeille afin qu\'ils puissent être restaurés ou supprimés définitivement. Les éléments plus anciens de la corbeille peuvent être supprimés automatiquement après un certain temps selon la configuration du système.',
     'maint_recycle_bin_open' => 'Ouvrir la corbeille',
 
@@ -92,18 +92,20 @@ return [
     'recycle_bin' => 'Corbeille',
     'recycle_bin_desc' => 'Ici, vous pouvez restaurer les éléments qui ont été supprimés ou choisir de les effacer définitivement du système. Cette liste n\'est pas filtrée contrairement aux listes d\'activités similaires dans le système pour lesquelles les filtres d\'autorisation sont appliqués.',
     'recycle_bin_deleted_item' => 'Élément supprimé',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Supprimé par',
     'recycle_bin_deleted_at' => 'Date de suppression',
     'recycle_bin_permanently_delete' => 'Supprimer définitivement',
     'recycle_bin_restore' => 'Restaurer',
     'recycle_bin_contents_empty' => 'La corbeille est vide',
-    'recycle_bin_empty' => 'Vider la Corbeille',
+    'recycle_bin_empty' => 'Vider la corbeille',
     'recycle_bin_empty_confirm' => 'Cela détruira définitivement tous les éléments de la corbeille, y compris le contenu contenu de chaque élément. Êtes-vous sûr de vouloir vider la corbeille ?',
     'recycle_bin_destroy_confirm' => 'Cette action supprimera définitivement cet élément, ainsi que tous les éléments enfants listés ci-dessous du système et vous ne pourrez pas restaurer ce contenu. Êtes-vous sûr de vouloir supprimer définitivement cet élément ?',
     'recycle_bin_destroy_list' => 'Éléments à détruire',
     'recycle_bin_restore_list' => 'Éléments à restaurer',
     'recycle_bin_restore_confirm' => 'Cette action restaurera l\'élément supprimé, y compris tous les éléments enfants, à leur emplacement d\'origine. Si l\'emplacement d\'origine a été supprimé depuis et est maintenant dans la corbeille, l\'élément parent devra également être restauré.',
     'recycle_bin_restore_deleted_parent' => 'Le parent de cet élément a également été supprimé. Ceux-ci resteront supprimés jusqu\'à ce que ce parent soit également restauré.',
+    'recycle_bin_restore_parent' => 'Restaurer le parent',
     'recycle_bin_destroy_notification' => ':count éléments totaux supprimés de la corbeille.',
     'recycle_bin_restore_notification' => ':count éléments totaux restaurés de la corbeille.',
 
@@ -115,9 +117,10 @@ return [
     'audit_deleted_item' => 'Élément supprimé',
     'audit_deleted_item_name' => 'Nom: :name',
     'audit_table_user' => 'Utilisateur',
-    'audit_table_event' => 'Evènement',
-    'audit_table_related' => 'Élément ou détail lié',
-    'audit_table_date' => 'Date d\'activation',
+    'audit_table_event' => 'Événement',
+    'audit_table_related' => 'Élément concerné ou action réalisée',
+    'audit_table_ip' => 'Adresse IP',
+    'audit_table_date' => 'Horodatage',
     'audit_date_from' => 'À partir du',
     'audit_date_to' => 'Jusqu\'au',
 
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Détails du rôle',
     'role_name' => 'Nom du rôle',
     'role_desc' => 'Courte description du rôle',
+    'role_mfa_enforced' => 'Nécessite une authentification multi-facteurs',
     'role_external_auth_id' => 'Identifiants d\'authentification externes',
     'role_system' => 'Permissions système',
     'role_manage_users' => 'Gérer les utilisateurs',
@@ -145,8 +149,9 @@ return [
     'role_manage_page_templates' => 'Gérer les modèles de page',
     'role_access_api' => 'Accès à l\'API du système',
     'role_manage_settings' => 'Gérer les préférences de l\'application',
+    'role_export_content' => 'Exporter le contenu',
     '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. Attribuer uniquement des rôles avec ces permissions à des utilisateurs de confiance.',
+    '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_all' => 'Tous',
@@ -161,7 +166,7 @@ return [
     'users' => 'Utilisateurs',
     'user_profile' => 'Profil d\'utilisateur',
     'users_add_new' => 'Ajouter un nouvel utilisateur',
-    'users_search' => 'Chercher les utilisateurs',
+    'users_search' => 'Rechercher les utilisateurs',
     'users_latest_activity' => 'Dernière activité',
     'users_details' => 'Informations de l\'utilisateur',
     'users_details_desc' => 'Définissez un nom et une adresse e-mail pour cet utilisateur. L\'adresse e-mail sera utilisée pour se connecter à l\'application.',
@@ -169,20 +174,20 @@ return [
     'users_role' => 'Rôles de l\'utilisateur',
     'users_role_desc' => 'Sélectionnez les rôles auxquels cet utilisateur sera affecté. Si un utilisateur est affecté à plusieurs rôles, les permissions de ces rôles s\'empileront et ils recevront toutes les capacités des rôles affectés.',
     'users_password' => 'Mot de passe de l\'utilisateur',
-    'users_password_desc' => 'Définissez un mot de passe utilisé pour vous connecter à l\'application. Il doit comporter au moins 5 caractères.',
-    'users_send_invite_text' => 'Vous pouvez choisir d\'envoyer à cet utilisateur un email d\'invitation qui lui permet de définir son propre mot de passe, sinon vous pouvez définir son mot de passe vous-même.',
+    'users_password_desc' => 'Définissez un mot de passe pour vous connecter à l\'application. Il doit comporter au moins 8 caractères.',
+    'users_send_invite_text' => 'Vous pouvez choisir d\'envoyer à cet utilisateur un e-mail d\'invitation qui lui permet de définir son propre mot de passe, sinon vous pouvez définir son mot de passe vous-même.',
     'users_send_invite_option' => 'Envoyer l\'e-mail d\'invitation',
     'users_external_auth_id' => 'Identifiant d\'authentification externe',
     'users_external_auth_id_desc' => 'C\'est l\'ID utilisé pour correspondre à cet utilisateur lors de la communication avec votre système d\'authentification externe.',
-    'users_password_warning' => 'Remplissez ce formulaire uniquement si vous souhaitez changer de mot de passe:',
+    'users_password_warning' => 'Remplissez ce formulaire uniquement si vous souhaitez changer de mot de passe :',
     'users_system_public' => 'Cet utilisateur représente les invités visitant votre instance. Il est assigné automatiquement aux invités.',
     'users_delete' => 'Supprimer un utilisateur',
     'users_delete_named' => 'Supprimer l\'utilisateur :userName',
     'users_delete_warning' => 'Ceci va supprimer \':userName\' du système.',
     'users_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?',
-    'users_migrate_ownership' => 'Migré propriété',
+    'users_migrate_ownership' => 'Transférer la propriété',
     'users_migrate_ownership_desc' => 'Sélectionnez un utilisateur ici si vous voulez qu\'un autre utilisateur devienne le propriétaire de tous les éléments actuellement détenus par cet utilisateur.',
-    'users_none_selected' => 'Aucun utilisateur n\'a été séléctionné',
+    'users_none_selected' => 'Aucun utilisateur n\'a été sélectionné',
     'users_delete_success' => 'Utilisateur supprimé avec succès',
     'users_edit' => 'Modifier l\'utilisateur',
     'users_edit_profile' => 'Modifier le profil',
@@ -191,17 +196,21 @@ return [
     'users_avatar_desc' => 'Cette image doit être un carré d\'environ 256 px.',
     'users_preferred_language' => 'Langue préférée',
     'users_preferred_language_desc' => 'Cette option changera la langue utilisée pour l\'interface utilisateur de l\'application. Ceci n\'affectera aucun contenu créé par l\'utilisateur.',
-    'users_social_accounts' => 'Comptes sociaux',
+    'users_social_accounts' => 'Réseaux sociaux',
     'users_social_accounts_info' => 'Vous pouvez connecter des réseaux sociaux à votre compte pour vous connecter plus rapidement. Déconnecter un compte n\'enlèvera pas les accès autorisés précédemment sur votre compte de réseau social.',
     'users_social_connect' => 'Connecter le compte',
     'users_social_disconnect' => 'Déconnecter le compte',
     'users_social_connected' => 'Votre compte :socialAccount a été ajouté avec succès.',
     'users_social_disconnected' => 'Votre compte :socialAccount a été déconnecté avec succès',
-    'users_api_tokens' => 'Jetons de l\'API',
+    'users_api_tokens' => 'Jetons API',
     'users_api_tokens_none' => 'Aucun jeton API n\'a été créé pour cet utilisateur',
     'users_api_tokens_create' => 'Créer un jeton',
     'users_api_tokens_expires' => 'Expiré',
     'users_api_tokens_docs' => 'Documentation de l\'API',
+    'users_mfa' => 'Authentification multi-facteurs',
+    'users_mfa_desc' => 'Configurer l\'authentification multi-facteurs ajoute une couche supplémentaire de sécurité à votre compte utilisateur.',
+    'users_mfa_x_methods' => ':count méthode configurée|:count méthodes configurées',
+    'users_mfa_configure' => 'Méthode de configuration',
 
     // API Tokens
     'user_api_token_create' => 'Créer un nouveau jeton API',
@@ -210,19 +219,47 @@ return [
     'user_api_token_expiry' => 'Date d\'expiration',
     'user_api_token_expiry_desc' => 'Définissez une date à laquelle ce jeton expire. Après cette date, les demandes effectuées à l\'aide de ce jeton ne fonctionneront plus. Le fait de laisser ce champ vide entraînera une expiration dans 100 ans.',
     'user_api_token_create_secret_message' => 'Immédiatement après la création de ce jeton, un "ID de jeton" "et" Secret de jeton "sera généré et affiché. Le secret ne sera affiché qu\'une seule fois, alors assurez-vous de copier la valeur dans un endroit sûr et sécurisé avant de continuer.',
-    'user_api_token_create_success' => 'L\'API token a été créé avec succès',
-    'user_api_token_update_success' => 'L\'API token a été mis à jour avec succès',
-    'user_api_token' => 'Token API',
+    'user_api_token_create_success' => 'Le jeton API a été créé avec succès',
+    'user_api_token_update_success' => 'Le jeton API a été mis à jour avec succès',
+    'user_api_token' => 'Jeton API',
     'user_api_token_id' => 'Token ID',
     'user_api_token_id_desc' => 'Il s\'agit d\'un identifiant généré par le système non modifiable pour ce jeton qui devra être fourni dans les demandes d\'API.',
     'user_api_token_secret' => 'Token Secret',
     'user_api_token_secret_desc' => 'Il s\'agit d\'un secret généré par le système pour ce jeton, qui devra être fourni dans les demandes d\'API. Cela ne sera affiché qu\'une seule fois, alors copiez cette valeur dans un endroit sûr et sécurisé.',
     'user_api_token_created' => 'Jeton créé :timeAgo',
     'user_api_token_updated' => 'Jeton mis à jour :timeAgo',
-    'user_api_token_delete' => 'Supprimer le Token',
+    'user_api_token_delete' => 'Supprimer le jeton',
     'user_api_token_delete_warning' => 'Cela supprimera complètement le jeton d\'API avec le nom \':tokenName\'.',
-    'user_api_token_delete_confirm' => 'Souhaitez-vous vraiment effacer l\'API Token ?',
-    'user_api_token_delete_success' => 'L\'API token a été supprimé avec succès',
+    'user_api_token_delete_confirm' => 'Souhaitez-vous vraiment effacer ce jeton API ?',
+    'user_api_token_delete_success' => 'Le jeton API a été supprimé avec succès',
+
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Créer un nouveau Webhook',
+    'webhooks_none_created' => 'Aucun webhook n\'a encore été créé.',
+    'webhooks_edit' => 'Éditer le Webhook',
+    'webhooks_save' => 'Enregistrer le Webhook',
+    'webhooks_details' => 'Détails du Webhook',
+    'webhooks_details_desc' => 'Renseignez un nom ainsi que votre endpoint POST sur lequel les données du webhook doivent être envoyées.',
+    'webhooks_events' => 'Événements du Webhook',
+    'webhooks_events_desc' => 'Sélectionnez tous les évènements qui doivent déclencher un appel sur ce webhook.',
+    'webhooks_events_warning' => 'Gardez à l\'esprit que ces événements seront déclenchés pour chaque événement sélectionné, même si des permissions personnalisées sont appliquées. Vérifiez bien que l\'utilisation de ce webhook n\'exposera pas de contenu confidentiel.',
+    'webhooks_events_all' => 'Tous les événements système',
+    'webhooks_name' => 'Nom du Webhook',
+    'webhooks_timeout' => 'Délai d\'expiration de requête du Webhook (en secondes)',
+    'webhooks_endpoint' => 'Point de terminaison du Webhook',
+    'webhooks_active' => 'Webhook actif',
+    'webhook_events_table_header' => 'Événements',
+    'webhooks_delete' => 'Supprimer le Webhook',
+    'webhooks_delete_warning' => 'Ceci supprimera complètement du système le webhook ayant le nom \':webhookName\'.',
+    'webhooks_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce webhook ?',
+    'webhooks_format_example' => 'Exemple de Format de Webhook',
+    'webhooks_format_example_desc' => 'Les données du webhook sont envoyées dans une requête POST vers l\'endpoint au format JSON respectant le format ci-dessous. Les propriétés "related_item" et "url" sont optionnelles et dépendront du type d\'événement déclenché.',
+    'webhooks_status' => 'Statut du webhook',
+    'webhooks_last_called' => 'Dernier appel :',
+    'webhooks_last_errored' => 'Dernier en erreur :',
+    'webhooks_last_error_message' => 'Dernier message d\'erreur : ',
+
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Estonien',
         'fr' => 'Français',
         'he' => 'Hébreu',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norvegien',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 684404f42d45c5ad921c0d95fa879c714c41dc40..71a5e8e083d9b6a420833d9472816c2f75c105a7 100644 (file)
@@ -15,10 +15,11 @@ return [
     'alpha_dash'           => ':attribute doit contenir uniquement des lettres, chiffres et traits d\'union.',
     'alpha_num'            => ':attribute doit contenir uniquement des chiffres et des lettres.',
     'array'                => ':attribute doit être un tableau.',
+    'backup_codes'         => 'Le code fourni n\'est pas valide ou a déjà été utilisé.',
     'before'               => ':attribute doit être inférieur à :date.',
     'between'              => [
         'numeric' => ':attribute doit être compris entre :min et :max.',
-        'file'    => ':attribute doit être compris entre :min et :max kilobytes.',
+        'file'    => ':attribute doit être compris entre :min et :max Ko.',
         'string'  => ':attribute doit être compris entre :min et :max caractères.',
         'array'   => ':attribute doit être compris entre :min et :max éléments.',
     ],
@@ -34,13 +35,13 @@ return [
     'filled'               => ':attribute est un champ requis.',
     'gt'                   => [
         'numeric' => ':attribute doit être plus grand que :value.',
-        'file'    => ':attribute doit être plus grand que :value kilobytes.',
+        'file'    => ':attribute doit être plus grand que :value Ko.',
         'string'  => ':attribute doit être plus grand que :value caractères.',
         'array'   => ':attribute doit avoir plus que :value éléments.',
     ],
     'gte'                  => [
         'numeric' => ':attribute doit être plus grand ou égal à :value.',
-        'file'    => ':attribute doit être plus grand ou égal à :value kilobytes.',
+        'file'    => ':attribute doit être plus grand ou égal à :value Ko.',
         'string'  => ':attribute doit être plus grand ou égal à :value caractères.',
         'array'   => ':attribute doit avoir :value éléments ou plus.',
     ],
@@ -52,22 +53,22 @@ return [
     'ip'                   => ':attribute doit être une adresse IP valide.',
     'ipv4'                 => ':attribute doit être une adresse IPv4 valide.',
     'ipv6'                 => ':attribute doit être une adresse IPv6 valide.',
-    'json'                 => ':attribute doit être une chaine JSON valide.',
+    'json'                 => ':attribute doit être une chaîne JSON valide.',
     'lt'                   => [
         'numeric' => ':attribute doit être plus petit que :value.',
-        'file'    => ':attribute doit être plus petit que :value kilobytes.',
+        'file'    => ':attribute doit être plus petit que :value Ko.',
         'string'  => ':attribute doit être plus petit que :value caractères.',
         'array'   => ':attribute doit avoir moins de :value éléments.',
     ],
     'lte'                  => [
         'numeric' => ':attribute doit être plus petit ou égal à :value.',
-        'file'    => ':attribute doit être plus petit ou égal à :value kilobytes.',
+        'file'    => ':attribute doit être plus petit ou égal à :value Ko.',
         'string'  => ':attribute doit être plus petit ou égal à :value caractères.',
         'array'   => ':attribute ne doit pas avoir plus de :value éléments.',
     ],
     'max'                  => [
         'numeric' => ':attribute ne doit pas excéder :max.',
-        'file'    => ':attribute ne doit pas excéder :max kilobytes.',
+        'file'    => ':attribute ne doit pas excéder :max Ko.',
         'string'  => ':attribute ne doit pas excéder :max caractères.',
         'array'   => ':attribute ne doit pas contenir plus de :max éléments.',
     ],
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute doit être une chaîne de caractères.',
     'timezone'             => ':attribute doit être une zone valide.',
+    'totp'                 => 'Le code fourni n\'est pas valide ou est expiré.',
     'unique'               => ':attribute est déjà utilisé.',
     'url'                  => ':attribute a un format invalide.',
     'uploaded'             => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.',
index 0babc38d17ed94ff4f2c7034983f230d3bda7fcd..83a374d66a083f3f25fc41523f87c0a58deab214 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'created page',
-    'page_create_notification'    => 'הדף נוצר בהצלחה',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'updated page',
-    'page_update_notification'    => 'הדף עודכן בהצלחה',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'deleted page',
-    'page_delete_notification'    => 'הדף הוסר בהצלחה',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'restored page',
-    'page_restore_notification'   => 'הדף שוחזר בהצלחה',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'moved page',
 
     // Chapters
     'chapter_create'              => 'created chapter',
-    'chapter_create_notification' => 'הפרק נוצר בהצלחה',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'updated chapter',
-    'chapter_update_notification' => 'הפרק עודכן בהצלחה',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'deleted chapter',
-    'chapter_delete_notification' => 'הפרק הוסר בהצלחה',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'moved chapter',
 
     // Books
     'book_create'                 => 'created book',
-    'book_create_notification'    => 'הספר נוצר בהצלחה',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'updated book',
-    'book_update_notification'    => 'הספר עודכן בהצלחה',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'deleted book',
-    'book_delete_notification'    => 'הספר הוסר בהצלחה',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorted book',
-    'book_sort_notification'      => 'הספר מוין מחדש בהצלחה',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'created Bookshelf',
-    'bookshelf_create_notification'    => 'מדף הספרים נוצר בהצלחה',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'updated bookshelf',
-    'bookshelf_update_notification'    => 'מדף הספרים עודכן בהצלחה',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'deleted bookshelf',
-    'bookshelf_delete_notification'    => 'מדף הספרים הוסר בהצלחה',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'commented on',
index 733c84f9de45bca28b12d202924152ce72b7676e..85b66c2a5405b13f76174f14b44cbee329e9648e 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'אי-מייל',
     'password' => 'סיסמא',
     'password_confirm' => 'אימות סיסמא',
-    'password_hint' => 'חייבת להיות יותר מ-5 תווים',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'שכחת סיסמא?',
     'remember_me' => 'זכור אותי',
     'ldap_email_hint' => 'אנא ציין כתובת אי-מייל לשימוש בחשבון זה',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'לא ניתן להרשם באמצעות המייל שסופק',
     'register_success' => 'תודה על הרשמתך! ניתן כעת להתחבר',
 
-
     // Password Reset
     'reset_password' => 'איפוס סיסמא',
     'reset_password_send_instructions' => 'יש להזין את כתובת המייל למטה ואנו נשלח אלייך הוראות לאיפוס הסיסמא',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'קישור זה נשלח עקב בקשה לאיפוס סיסמא בחשבון שלך',
     'email_reset_not_requested' => 'אם לא ביקשת לאפס את סיסמתך, אפשר להתעלם ממייל זה',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'אמת אי-מייל ב :appName',
     'email_confirm_greeting' => 'תודה שהצטרפת אל :appName!',
     'email_confirm_text' => 'יש לאמת את כתובת המייל של על ידי לחיצה על הכפור למטה:',
     'email_confirm_action' => 'אמת כתובת אי-מייל',
     'email_confirm_send_error' => 'נדרש אימות אי-מייל אך שליחת האי-מייל אליך נכשלה. יש ליצור קשר עם מנהל המערכת כדי לוודא שאכן ניתן לשלוח מיילים.',
-    'email_confirm_success' => 'האי-מייל שלך אושר!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'אימות נשלח לאי-מייל שלך, יש לבדוק בתיבת הדואר הנכנס',
 
     'email_not_confirmed' => 'כתובת המייל לא אומתה',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Welcome to :appName!',
     'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
     'user_invite_page_confirm_button' => 'Confirm Password',
-    'user_invite_success' => 'Password set, you now have access to :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 9cbd047fba0a8882f19462e6491cd5a7541c195c..2f1f4ef35b4e1d2aa5d078b7fb51f58907664ffc 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'תפקיד',
     'cover_image' => 'תמונת נושא',
     'cover_image_description' => 'התמונה צריכה להיות בסביבות 440x250px',
-    
+
     // Actions
     'actions' => 'פעולות',
     'view' => 'הצג',
@@ -39,7 +39,14 @@ return [
     'reset' => 'איפוס',
     'remove' => 'הסר',
     'add' => 'הוסף',
+    'configure' => 'Configure',
     'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sort Options',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'אין פעילות להציג',
     'no_items' => 'אין פריטים זמינים',
     'back_to_top' => 'בחזרה ללמעלה',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'הצג/הסתר פרטים',
     'toggle_thumbnails' => 'הצג/הסתר תמונות',
     'details' => 'פרטים',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'תצוגת רשימה',
     'default' => 'ברירת מחדל',
     'breadcrumb' => 'Breadcrumb',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index c8d239bca6cf711a365a1a4e1e69f44ba7e9ba57..b5ee70c2bda1de3f7965b37c1653bb4aea42f715 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'תמונות',
     'my_recent_drafts' => 'הטיוטות האחרונות שלי',
     'my_recently_viewed' => 'הנצפים לאחרונה שלי',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'לא צפית בדפים כלשהם',
     'no_pages_recently_created' => 'לא נוצרו דפים לאחרונה',
     'no_pages_recently_updated' => 'לא עודכנו דפים לאחרונה',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'דף אינטרנט',
     'export_pdf' => 'קובץ PDF',
     'export_text' => 'טקסט רגיל',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'הרשאות',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'הרשאות מדף',
     'shelves_permissions_updated' => 'הרשאות מדף עודכנו',
     'shelves_permissions_active' => 'הרשאות מדף פעילות',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'העתק הרשאות מדף אל הספרים',
     'shelves_copy_permissions' => 'העתק הרשאות',
     'shelves_copy_permissions_explain' => 'פעולה זו תעתיק את כל הרשאות המדף לכל הספרים המשוייכים למדף זה. לפני הביצוע, יש לוודא שכל הרשאות המדף אכן נשמרו.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'פרקים בסוף',
     'books_sort_show_other' => 'הצג ספרים אחרונים',
     'books_sort_save' => 'שמור את הסדר החדש',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'פרק',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'העבר פרק',
     'chapters_move_named' => 'העבר פרק :chapterName',
     'chapter_move_success' => 'הפרק הועבר אל :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'הרשאות פרק',
     'chapters_empty' => 'לא נמצאו דפים בפרק זה.',
     'chapters_permissions_active' => 'הרשאות פרק פעילות',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'דף חדש',
     'pages_editing_draft_notification' => 'הינך עורך טיוטה אשר נשמרה לאחרונה ב :timeDiff',
     'pages_draft_edited_notification' => 'דף זה עודכן מאז, מומלץ להתעלם מהטיוטה הזו.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count משתמשים החלו לערוך דף זה',
         'start_b' => ':userName החל לערוך דף זה',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "הכנס תגיות על מנת לסדר את התוכן שלך. \n  ניתן לציין ערך לתגית על מנת לבצע סידור יסודי יותר",
     'tags_add' => 'הוסף תגית נוספת',
     'tags_remove' => 'Remove this tag',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'קבצים מצורפים',
     'attachments_explain' => 'צרף קבצים או קישורים על מנת להציגם בדף שלך. צירופים אלו יהיו זמינים בתפריט הצדדי של הדף',
     'attachments_explain_instant_save' => 'שינויים נשמרים באופן מיידי',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'האם ברצונך למחוק נוסח זה?',
     'revision_restore_confirm' => 'האם ברצונך לשחזר נוסח זה? תוכן הדף הנוכחי יעודכן לנוסח זה.',
     'revision_delete_success' => 'נוסח נמחק',
-    'revision_cannot_delete_latest' => 'לא ניתן למחוק את הנוסח האחרון'
+    'revision_cannot_delete_latest' => 'לא ניתן למחוק את הנוסח האחרון',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index f6a01d3a3ff4d9c94ddc1c2ec980793cb0b63530..f5836082d210f09e7fe062d87bdb460e9d0b7be9 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
     'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
     'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'לא הוגדרה פעולה',
     'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
     'social_account_in_use' => 'החשבון :socialAccount כבר בשימוש. אנא נסה להתחבר באמצעות אפשרות :socialAccount',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'דף לא קיים',
     'sorry_page_not_found' => 'מצטערים, הדף שחיפשת אינו קיים',
     'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'בחזרה לדף הבית',
     'error_occurred' => 'התרחשה שגיאה',
     'app_down' => ':appName כרגע אינו זמין',
index b7ce527693a0cbc986ce3a36d01b59c9b3f888c0..294a62bc4cc3c2c0af4d5101ab6978ae5c4bed2f 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'תחזוקה',
     'maint_image_cleanup' => 'ניקוי תמונות',
-    'maint_image_cleanup_desc' => "סורק את הדפים והגרסאות על מנת למצוא אילו תמונות לא בשימוש. יש לוודא גיבוי מלא של מסד הנתונים והתמונות לפני הרצה",
+    'maint_image_cleanup_desc' => 'סורק את הדפים והגרסאות על מנת למצוא אילו תמונות לא בשימוש. יש לוודא גיבוי מלא של מסד הנתונים והתמונות לפני הרצה',
     'maint_delete_images_only_in_revisions' => 'מחק בנוסף תמונות שקיימות בגרסאות ישנות של הדף בלבד',
     'maint_image_cleanup_run' => 'הפעל ניקוי תמונות',
     'maint_image_cleanup_warning' => 'נמצאו כ :count תמונות אשר לא בשימוש האם ברצונך להמשיך?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'סל המיחזור',
     'recycle_bin_desc' => 'כאן תוכלו לאחזר פריטים שנמחקו או לבחור למחוק אותם מהמערכת לצמיתות. רשימה זו לא מסוננת, בשונה מרשימות פעילות דומות במערכת, בהן מוחלים מסנני הרשאות.',
     'recycle_bin_deleted_item' => 'פריט שנמחק',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'נמחק על ידי',
     'recycle_bin_deleted_at' => 'זמן המחיקה',
     'recycle_bin_permanently_delete' => 'מחק לצמיתות',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'פריטים שיאוחזרו',
     'recycle_bin_restore_confirm' => 'פעולה זו תאחזר את הפריט שנמחק, לרבות רכיבי-הבן שלו, למיקומו המקורי. אם המיקום המקורי נמחק מאז, וכעת נמצא בסל המיחזור, יש לאחזר גם את פריט-האב.',
     'recycle_bin_restore_deleted_parent' => 'פריט-האב של פריט זה נמחק. פריטים אלה יישארו מחוקים עד שפריט-אב זה יאוחזר.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'נמחקו בסה"כ :count פריטים מסל המיחזור.',
     'recycle_bin_restore_notification' => 'אוחזרו בסה"כ :count פריטים מסל המיחזור.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'משתמש',
     'audit_table_event' => 'אירוע',
     'audit_table_related' => 'פריט או פרט קשור',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'זמן הפעילות',
     'audit_date_from' => 'טווח תאריכים החל מ...',
     'audit_date_to' => 'טווח תאריכים עד ל...',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'פרטי תפקיד',
     'role_name' => 'שם התפקיד',
     'role_desc' => 'תיאור קצר של התפקיד',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'ID-י אותנטיקציה חיצוניים',
     'role_system' => 'הרשאות מערכת',
     'role_manage_users' => 'ניהול משתמשים',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'נהל תבניות דפים',
     'role_access_api' => 'גש ל-API המערכת',
     'role_manage_settings' => 'ניהול הגדרות יישום',
+    'role_export_content' => 'Export content',
     'role_asset' => 'הרשאות משאבים',
     'roles_system_warning' => 'שימו לב לכך שגישה לכל אחת משלושת ההרשאות הנ"ל יכולה לאפשר למשתמש לשנות את הפריווילגיות שלהם או של אחרים במערכת. הגדירו תפקידים להרשאות אלה למשתמשים בהם אתם בוטחים בלבד.',
     'role_asset_desc' => 'הרשאות אלו שולטות בגישת ברירת המחדל למשאבים בתוך המערכת. הרשאות של ספרים, פרקים ודפים יגברו על הרשאות אלו.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'תפקידי משתמשים',
     'users_role_desc' => 'בחר אילו תפקידים ישויכו למשתמש זה. אם המשתמש משוייך למספר תפקידים, ההרשאות יהיו כלל ההרשאות של כל התפקידים',
     'users_password' => 'סיסמא',
-    'users_password_desc' => 'הגדר סיסמא עבור גישה למערכת. על הסיסמא להיות באורך של 5 תווים לפחות',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'תוכלו לבחור לשלוח למשתמש זה דוא"ל הזמנה, המאפשר להם להגדיר סיסמה משלהם. אחרת, תוכלו להגדיר את סיסמתם בעצמכם.',
     'users_send_invite_option' => 'שלח דוא"ל הזמנה למשתמש',
     'users_external_auth_id' => 'זיהוי חיצוני - ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'צור אסימון',
     'users_api_tokens_expires' => 'פג',
     'users_api_tokens_docs' => 'תיעוד API',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'צור אסימון API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'האם אתם בטוחים שאתם מעוניינים למחוק אסימון API זה?',
     'user_api_token_delete_success' => 'אסימון API נמחק בהצלחה',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 8a18ca27a21c3c85a81a18c891d3ba88553e0247..e52c28b42ec58ca2de43e469397716b27aa56b50 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'שדה :attribute יכול להכיל אותיות, מספרים ומקפים בלבד.',
     'alpha_num'            => 'שדה :attribute יכול להכיל אותיות ומספרים בלבד.',
     'array'                => 'שדה :attribute חייב להיות מערך.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => 'שדה :attribute חייב להיות תאריך לפני :date.',
     'between'              => [
         'numeric' => 'שדה :attribute חייב להיות בין :min ל-:max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'שדה :attribute חייב להיות מחרוזת.',
     'timezone'             => 'שדה :attribute חייב להיות איזור תקני.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => 'שדה :attribute כבר תפוס.',
     'url'                  => 'שדה :attribute בעל פורמט שאינו תקין.',
     'uploaded'             => 'שדה :attribute ארעה שגיאה בעת ההעלאה.',
diff --git a/resources/lang/hr/activities.php b/resources/lang/hr/activities.php
new file mode 100644 (file)
index 0000000..58f4dab
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'stvorena stranica',
+    'page_create_notification'    => 'Page successfully created',
+    'page_update'                 => 'ažurirana stranica',
+    'page_update_notification'    => 'Page successfully updated',
+    'page_delete'                 => 'izbrisana stranica',
+    'page_delete_notification'    => 'Page successfully deleted',
+    'page_restore'                => 'obnovljena stranica',
+    'page_restore_notification'   => 'Page successfully restored',
+    'page_move'                   => 'premještena stranica',
+
+    // Chapters
+    'chapter_create'              => 'stvoreno poglavlje',
+    'chapter_create_notification' => 'Chapter successfully created',
+    'chapter_update'              => 'ažurirano poglavlje',
+    'chapter_update_notification' => 'Chapter successfully updated',
+    'chapter_delete'              => 'izbrisano poglavlje',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
+    'chapter_move'                => 'premiješteno poglavlje',
+
+    // Books
+    'book_create'                 => 'stvorena knjiga',
+    'book_create_notification'    => 'Book successfully created',
+    'book_update'                 => 'ažurirana knjiga',
+    'book_update_notification'    => 'Book successfully updated',
+    'book_delete'                 => 'izbrisana knjiga',
+    'book_delete_notification'    => 'Book successfully deleted',
+    'book_sort'                   => 'razvrstana knjiga',
+    'book_sort_notification'      => 'Book successfully re-sorted',
+
+    // Bookshelves
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
+    'bookshelf_update'                 => 'ažurirana polica za knjige',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
+    'bookshelf_delete'                 => 'izbrisana polica za knjige',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
+
+    // Other
+    'commented_on'                => 'komentirano',
+    'permissions_update'          => 'ažurirana dopuštenja',
+];
diff --git a/resources/lang/hr/auth.php b/resources/lang/hr/auth.php
new file mode 100644 (file)
index 0000000..aff8ea4
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Authentication Language Lines
+ * The following language lines are used during authentication for various
+ * messages that we need to display to the user.
+ */
+return [
+
+    'failed' => 'Ove vjerodajnice ne podudaraju se s našim zapisima.',
+    'throttle' => 'Previše pokušaja prijave. Molimo vas da pokušate za :seconds sekundi.',
+
+    // Login & Register
+    'sign_up' => 'Registrirajte se',
+    'log_in' => 'Prijavite se',
+    'log_in_with' => 'Prijavite se sa :socialDriver',
+    'sign_up_with' => 'Registrirajte se sa :socialDriver',
+    'logout' => 'Odjavite se',
+
+    'name' => 'Ime',
+    'username' => 'Korisničko ime',
+    'email' => 'Email',
+    'password' => 'Lozinka',
+    'password_confirm' => 'Potvrdite lozinku',
+    'password_hint' => 'Must be at least 8 characters',
+    'forgot_password' => 'Zaboravili ste lozinku?',
+    'remember_me' => 'Zapamti me',
+    'ldap_email_hint' => 'Molimo upišite mail korišten za ovaj račun.',
+    'create_account' => 'Stvori račun',
+    'already_have_account' => 'Imate li već račun?',
+    'dont_have_account' => 'Nemate račun?',
+    'social_login' => 'Social Login',
+    'social_registration' => 'Social Registration',
+    'social_registration_text' => 'Prijavite se putem drugih servisa.',
+
+    'register_thanks' => 'Zahvaljujemo na registraciji!',
+    'register_confirm' => 'Molimo, provjerite svoj email i kliknite gumb za potvrdu pristupa :appName.',
+    'registrations_disabled' => 'Registracije su trenutno onemogućene',
+    'registration_email_domain_invalid' => 'Ova e-mail adresa se ne može koristiti u ovoj aplikaciji',
+    'register_success' => 'Hvala na prijavi! Sada ste registrirani i prijavljeni.',
+
+    // Password Reset
+    'reset_password' => 'Promijenite lozinku',
+    'reset_password_send_instructions' => 'Upišite svoju e-mail adresu kako biste primili poveznicu za promjenu lozinke.',
+    'reset_password_send_button' => 'Pošalji poveznicu za promjenu lozinke',
+    'reset_password_sent' => 'Poveznica za promjenu lozinke poslat će se na :email adresu ako je u našem sustavu.',
+    'reset_password_success' => 'Vaša lozinka je uspješno promijenjena.',
+    'email_reset_subject' => 'Promijenite svoju :appName lozinku',
+    'email_reset_text' => 'Primili ste ovu poruku jer je zatražena promjena lozinke za vaš račun.',
+    'email_reset_not_requested' => 'Ako niste tražili promjenu lozinke slobodno zanemarite ovu poruku.',
+
+    // Email Confirmation
+    'email_confirm_subject' => 'Potvrdite svoju e-mail adresu na :appName',
+    'email_confirm_greeting' => 'Hvala na prijavi :appName!',
+    'email_confirm_text' => 'Molimo potvrdite svoju e-mail adresu klikom na donji gumb.',
+    'email_confirm_action' => 'Potvrdi Email',
+    'email_confirm_send_error' => 'Potvrda e-mail adrese je obavezna, ali sustav ne može poslati e-mail. Javite se administratoru kako bi provjerio vaš e-mail.',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
+    'email_confirm_resent' => 'Ponovno je poslana potvrda. Molimo, provjerite svoj inbox.',
+
+    'email_not_confirmed' => 'E-mail adresa nije potvrđena.',
+    'email_not_confirmed_text' => 'Vaša e-mail adresa još nije potvrđena.',
+    'email_not_confirmed_click_link' => 'Molimo, kliknite na poveznicu koju ste primili kratko nakon registracije.',
+    'email_not_confirmed_resend' => 'Ako ne možete pronaći e-mail za postavljanje lozinke možete ga zatražiti ponovno ispunjavanjem ovog obrasca.',
+    'email_not_confirmed_resend_button' => 'Ponovno pošalji e-mail potvrde',
+
+    // User Invite
+    'user_invite_email_subject' => 'Pozvani ste pridružiti se :appName!',
+    'user_invite_email_greeting' => 'Vaš račun je kreiran za vas na :appName',
+    'user_invite_email_text' => 'Kliknite ispod da biste postavili račun i dobili pristup.',
+    'user_invite_email_action' => 'Postavite lozinku',
+    'user_invite_page_welcome' => 'Dobrodošli u :appName!',
+    'user_invite_page_text' => 'Da biste postavili račun i dobili pristup trebate unijeti lozinku kojom ćete se ubuduće prijaviti na :appName.',
+    'user_invite_page_confirm_button' => 'Potvrdite lozinku',
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
diff --git a/resources/lang/hr/common.php b/resources/lang/hr/common.php
new file mode 100644 (file)
index 0000000..5e4a8f4
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Odustani',
+    'confirm' => 'Potvrdi',
+    'back' => 'Natrag',
+    'save' => 'Spremi',
+    'continue' => 'Nastavi',
+    'select' => 'Odaberi',
+    'toggle_all' => 'Prebaci sve',
+    'more' => 'Više',
+
+    // Form Labels
+    'name' => 'Ime',
+    'description' => 'Opis',
+    'role' => 'Uloga',
+    'cover_image' => 'Naslovna slika',
+    'cover_image_description' => 'Slika treba biti približno 440x250px.',
+
+    // Actions
+    'actions' => 'Aktivnost',
+    'view' => 'Pogled',
+    'view_all' => 'Pogledaj sve',
+    'create' => 'Stvori',
+    'update' => 'Ažuriraj',
+    'edit' => 'Uredi',
+    'sort' => 'Razvrstaj',
+    'move' => 'Makni',
+    'copy' => 'Kopiraj',
+    'reply' => 'Ponovi',
+    'delete' => 'Izbriši',
+    'delete_confirm' => 'Potvrdite brisanje',
+    'search' => 'Traži',
+    'search_clear' => 'Očisti pretragu',
+    'reset' => 'Ponovno postavi',
+    'remove' => 'Ukloni',
+    'add' => 'Dodaj',
+    'configure' => 'Configure',
+    'fullscreen' => 'Cijeli zaslon',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
+
+    // Sort Options
+    'sort_options' => 'Razvrstaj opcije',
+    'sort_direction_toggle' => 'Razvrstaj smjer prebacivanja',
+    'sort_ascending' => 'Razvrstaj uzlazno',
+    'sort_descending' => 'Razvrstaj silazno',
+    'sort_name' => 'Ime',
+    'sort_default' => 'Zadano',
+    'sort_created_at' => 'Datum',
+    'sort_updated_at' => 'Ažuriraj datum',
+
+    // Misc
+    'deleted_user' => 'Izbrisani korisnik',
+    'no_activity' => 'Nema aktivnosti za pregled',
+    'no_items' => 'Nedostupno',
+    'back_to_top' => 'Natrag na vrh',
+    'skip_to_main_content' => 'Skip to main content',
+    'toggle_details' => 'Prebaci detalje',
+    'toggle_thumbnails' => 'Uključi minijature',
+    'details' => 'Detalji',
+    'grid_view' => 'Prikaz rešetke',
+    'list_view' => 'Prikaz popisa',
+    'default' => 'Zadano',
+    'breadcrumb' => 'Breadcrumb',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
+
+    // Header
+    'header_menu_expand' => 'Proširi izbornik',
+    'profile_menu' => 'Profil',
+    'view_profile' => 'Vidi profil',
+    'edit_profile' => 'Uredite profil',
+    'dark_mode' => 'Tamni način',
+    'light_mode' => 'Svijetli način',
+
+    // Layout tabs
+    'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: pokaži sekundarne informacije',
+    'tab_content' => 'Sadržaj',
+    'tab_content_label' => 'Tab: pokaži primarni sadržaj',
+
+    // Email Content
+    'email_action_help' => 'Ako imate poteškoća s klikom na gumb ":actionText", kopirajte i zalijepite donji URL u vaš preglednik.',
+    'email_rights' => 'Sva prava pridržana',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Politika privatnosti',
+    'terms_of_service' => 'Uvjeti korištenja',
+];
diff --git a/resources/lang/hr/components.php b/resources/lang/hr/components.php
new file mode 100644 (file)
index 0000000..5caffd5
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Odabir slike',
+    'image_all' => 'Sve',
+    'image_all_title' => 'Vidi sve slike',
+    'image_book_title' => 'Vidi slike dodane ovoj knjizi',
+    'image_page_title' => 'Vidi slike dodane ovoj stranici',
+    'image_search_hint' => 'Pretraži pomoću imena slike',
+    'image_uploaded' => 'Učitano :uploadedDate',
+    'image_load_more' => 'Učitaj više',
+    'image_image_name' => 'Ime slike',
+    'image_delete_used' => 'Ova slika korištena je na donjoj stranici.',
+    'image_delete_confirm_text' => 'Jeste li sigurni da želite obrisati sliku?',
+    'image_select_image' => 'Odaberi sliku',
+    'image_dropzone' => 'Prebacite sliku ili kliknite ovdje za prijenos',
+    'images_deleted' => 'Obrisane slike',
+    'image_preview' => 'Pregled slike',
+    'image_upload_success' => 'Slika je uspješno dodana',
+    'image_update_success' => 'Detalji slike su uspješno ažurirani',
+    'image_delete_success' => 'Slika je obrisana',
+    'image_upload_remove' => 'Ukloni',
+
+    // Code Editor
+    'code_editor' => 'Uredi kod',
+    'code_language' => 'Jezik koda',
+    'code_content' => 'Sadržaj koda',
+    'code_session_history' => 'Povijest sesije',
+    'code_save' => 'Spremi kod',
+];
diff --git a/resources/lang/hr/entities.php b/resources/lang/hr/entities.php
new file mode 100644 (file)
index 0000000..333fdeb
--- /dev/null
@@ -0,0 +1,347 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Nedavno stvoreno',
+    'recently_created_pages' => 'Nedavno stvorene stranice',
+    'recently_updated_pages' => 'Nedavno ažurirane stranice',
+    'recently_created_chapters' => 'Nedavno stvorena poglavlja',
+    'recently_created_books' => 'Nedavno stvorene knjige',
+    'recently_created_shelves' => 'Nedavno stvorene police',
+    'recently_update' => 'Nedavno ažurirano',
+    'recently_viewed' => 'Nedavno viđeno',
+    'recent_activity' => 'Nedavna aktivnost',
+    'create_now' => 'Stvori sada',
+    'revisions' => 'Revizije',
+    'meta_revision' => 'Revizija #:revisionCount',
+    'meta_created' => 'Stvoreno :timeLength',
+    'meta_created_name' => 'Stvoreno :timeLength od :user',
+    'meta_updated' => 'Ažurirano :timeLength',
+    'meta_updated_name' => 'Ažurirano :timeLength od :user',
+    'meta_owned_name' => 'Vlasništvo :user',
+    'entity_select' => 'Odaberi subjekt',
+    'images' => 'Slike',
+    'my_recent_drafts' => 'Nedavne skice',
+    'my_recently_viewed' => 'Nedavno viđeno',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
+    'no_pages_viewed' => 'Niste pogledali nijednu stranicu',
+    'no_pages_recently_created' => 'Nema nedavno stvorenih stranica',
+    'no_pages_recently_updated' => 'Nema nedavno ažuriranih stranica',
+    'export' => 'Izvoz',
+    'export_html' => 'Web File',
+    'export_pdf' => 'PDF File',
+    'export_text' => 'Text File',
+    'export_md' => 'Markdown File',
+
+    // Permissions and restrictions
+    'permissions' => 'Dopuštenja',
+    'permissions_intro' => 'Jednom postavljene, ove dozvole bit će prioritetne ostalim dopuštenjima.',
+    'permissions_enable' => 'Omogući dopuštenje za korištenje',
+    'permissions_save' => 'Spremi dopuštenje',
+    'permissions_owner' => 'Vlasnik',
+
+    // Search
+    'search_results' => 'Pretraži rezultate',
+    'search_total_results_found' => ':count rezultat|:count ukupno pronađenih rezultata',
+    'search_clear' => 'Očisti pretragu',
+    'search_no_pages' => 'Nijedna stranica ne podudara se s ovim pretraživanjem',
+    'search_for_term' => 'Traži :term',
+    'search_more' => 'Više rezultata',
+    'search_advanced' => 'Napredno pretraživanje',
+    'search_terms' => 'Pretraži pojmove',
+    'search_content_type' => 'Vrsta sadržaja',
+    'search_exact_matches' => 'Podudarnosti',
+    'search_tags' => 'Označi pretragu',
+    'search_options' => 'Opcije',
+    'search_viewed_by_me' => 'Pregledano od mene',
+    'search_not_viewed_by_me' => 'Nije pregledano od mene',
+    'search_permissions_set' => 'Set dopuštenja',
+    'search_created_by_me' => 'Stvoreno od mene',
+    'search_updated_by_me' => 'Ažurirano od mene',
+    'search_owned_by_me' => 'Moje vlasništvo',
+    'search_date_options' => 'Opcije datuma',
+    'search_updated_before' => 'Ažurirano prije',
+    'search_updated_after' => 'Ažurirano nakon',
+    'search_created_before' => 'Stvoreno prije',
+    'search_created_after' => 'Stvoreno nakon',
+    'search_set_date' => 'Datumi',
+    'search_update' => 'Ažuriraj pretragu',
+
+    // Shelves
+    'shelf' => 'Polica',
+    'shelves' => 'Police',
+    'x_shelves' => ':count polica|:count polica',
+    'shelves_long' => 'Police za knjige',
+    'shelves_empty' => 'Nijedna polica nije stvorena',
+    'shelves_create' => 'Stvori novu policu',
+    'shelves_popular' => 'Popularne police',
+    'shelves_new' => 'Nove police',
+    'shelves_new_action' => 'Nova polica',
+    'shelves_popular_empty' => 'Najpopularnije police pojavit će se. ovdje.',
+    'shelves_new_empty' => 'Nedavno stvorene police pojavit će se ovdje.',
+    'shelves_save' => 'Spremi policu',
+    'shelves_books' => 'Knjige na ovoj polici',
+    'shelves_add_books' => 'Dodaj knjige na ovu policu',
+    'shelves_drag_books' => 'Prebaci knjige na ovu policu',
+    'shelves_empty_contents' => 'Ova polica još nema dodijeljene knjige',
+    'shelves_edit_and_assign' => 'Uredi policu za dodavanje knjiga',
+    'shelves_edit_named' => 'Uredi policu :name',
+    'shelves_edit' => 'Uredi policu',
+    'shelves_delete' => 'Izbriši policu',
+    'shelves_delete_named' => 'Izbriši policu :name',
+    'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
+    'shelves_delete_confirmation' => 'Jeste li sigurni da želite obrisati policu?',
+    'shelves_permissions' => 'Dopuštenja za policu',
+    'shelves_permissions_updated' => 'Ažurirana dopuštenja za policu',
+    'shelves_permissions_active' => 'Aktivirana dopuštenja za policu',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
+    'shelves_copy_permissions_to_books' => 'Kopiraj dopuštenja za knjige',
+    'shelves_copy_permissions' => 'Kopiraj dopuštenja',
+    'shelves_copy_permissions_explain' => 'Ovo će promijeniti trenutna dopuštenja za policu i knjige u njoj. Prije aktivacije provjerite jesu li sve dopuštenja za ovu policu spremljena.',
+    'shelves_copy_permission_success' => 'Dopuštenja za policu kopirana za :count knjiga',
+
+    // Books
+    'book' => 'Knjiga',
+    'books' => 'Knjige',
+    'x_books' => ':count knjiga|:count knjiga',
+    'books_empty' => 'Nijedna knjiga nije stvorena',
+    'books_popular' => 'Popularne knjige',
+    'books_recent' => 'Nedavne knjige',
+    'books_new' => 'Nove knjige',
+    'books_new_action' => 'Nova knjiga',
+    'books_popular_empty' => 'Najpopularnije knjige pojavit će se ovdje.',
+    'books_new_empty' => 'Najnovije knjige pojavit će se ovdje.',
+    'books_create' => 'Stvori novu knjigu',
+    'books_delete' => 'Izbriši knjigu',
+    'books_delete_named' => 'Izbriši knjigu :bookName',
+    'books_delete_explain' => 'Ovaj korak će izbrisati knjigu \':bookName\'. Izbrisati će sve stranice i poglavlja.',
+    'books_delete_confirmation' => 'Jeste li sigurni da želite izbrisati ovu knjigu?',
+    'books_edit' => 'Uredi knjigu',
+    'books_edit_named' => 'Uredi knjigu :bookName',
+    'books_form_book_name' => 'Ime knjige',
+    'books_save' => 'Spremi knjigu',
+    'books_permissions' => 'Dopuštenja za knjigu',
+    'books_permissions_updated' => 'Ažurirana dopuštenja za knjigu',
+    'books_empty_contents' => 'U ovoj knjizi još nema stranica ni poglavlja.',
+    'books_empty_create_page' => 'Stvori novu stranicu',
+    'books_empty_sort_current_book' => 'Razvrstaj postojeće knjige',
+    'books_empty_add_chapter' => 'Dodaj poglavlje',
+    'books_permissions_active' => 'Aktivna dopuštenja za knjigu',
+    'books_search_this' => 'Traži knjigu',
+    'books_navigation' => 'Navigacija knjige',
+    'books_sort' => 'Razvrstaj sadržaj knjige',
+    'books_sort_named' => 'Razvrstaj knjigu :bookName',
+    'books_sort_name' => 'Razvrstaj po imenu',
+    'books_sort_created' => 'Razvrstaj po datumu nastanka',
+    'books_sort_updated' => 'Razvrstaj po datumu ažuriranja',
+    'books_sort_chapters_first' => 'Prva poglavlja',
+    'books_sort_chapters_last' => 'Zadnja poglavlja',
+    'books_sort_show_other' => 'Pokaži ostale knjige',
+    'books_sort_save' => 'Spremi novi poredak',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
+
+    // Chapters
+    'chapter' => 'Poglavlje',
+    'chapters' => 'Poglavlja',
+    'x_chapters' => ':count poglavlje|:count poglavlja',
+    'chapters_popular' => 'Popularna poglavlja',
+    'chapters_new' => 'Novo poglavlje',
+    'chapters_create' => 'Stvori novo poglavlje',
+    'chapters_delete' => 'Izbriši poglavlje',
+    'chapters_delete_named' => 'Izbriši poglavlje :chapterName',
+    'chapters_delete_explain' => 'Ovaj korak briše poglavlje \':chapterName\'. Sve stranice u njemu će biti izbrisane.',
+    'chapters_delete_confirm' => 'Jeste li sigurni da želite izbrisati poglavlje?',
+    'chapters_edit' => 'Uredi poglavlje',
+    'chapters_edit_named' => 'Uredi poglavlje :chapterName',
+    'chapters_save' => 'Spremi poglavlje',
+    'chapters_move' => 'Premjesti poglavlje',
+    'chapters_move_named' => 'Premjesti poglavlje :chapterName',
+    'chapter_move_success' => 'Poglavlje premješteno u :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
+    'chapters_permissions' => 'Dopuštenja za poglavlje',
+    'chapters_empty' => 'U ovom poglavlju nema stranica.',
+    'chapters_permissions_active' => 'Aktivna dopuštenja za poglavlje',
+    'chapters_permissions_success' => 'Ažurirana dopuštenja za poglavlje',
+    'chapters_search_this' => 'Pretraži poglavlje',
+
+    // Pages
+    'page' => 'Stranica',
+    'pages' => 'Stranice',
+    'x_pages' => ':count stranice|:count stranica',
+    'pages_popular' => 'Popularne stranice',
+    'pages_new' => 'Nova stranica',
+    'pages_attachments' => 'Prilozi',
+    'pages_navigation' => 'Navigacija stranice',
+    'pages_delete' => 'Izbriši stranicu',
+    'pages_delete_named' => 'Izbriši stranicu :pageName',
+    'pages_delete_draft_named' => 'Izbriši nacrt stranice :pageName',
+    'pages_delete_draft' => 'Izbriši nacrt stranice',
+    'pages_delete_success' => 'Izbrisana stranica',
+    'pages_delete_draft_success' => 'Izbrisan nacrt stranice',
+    'pages_delete_confirm' => 'Jeste li sigurni da želite izbrisati stranicu?',
+    'pages_delete_draft_confirm' => 'Jeste li sigurni da želite izbrisati nacrt stranice?',
+    'pages_editing_named' => 'Uređivanje stranice :pageName',
+    'pages_edit_draft_options' => 'Izrada skice',
+    'pages_edit_save_draft' => 'Spremi nacrt',
+    'pages_edit_draft' => 'Uredi nacrt stranice',
+    'pages_editing_draft' => 'Uređivanja nacrta',
+    'pages_editing_page' => 'Uređivanje stranice',
+    'pages_edit_draft_save_at' => 'Nacrt spremljen kao',
+    'pages_edit_delete_draft' => 'Izbriši nacrt',
+    'pages_edit_discard_draft' => 'Odbaci nacrt',
+    'pages_edit_set_changelog' => 'Postavi dnevnik promjena',
+    'pages_edit_enter_changelog_desc' => 'Ukratko opišite promjene koje ste napravili',
+    'pages_edit_enter_changelog' => 'Unesi dnevnik promjena',
+    'pages_save' => 'Spremi stranicu',
+    'pages_title' => 'Naslov stranice',
+    'pages_name' => 'Ime stranice',
+    'pages_md_editor' => 'Uređivač',
+    'pages_md_preview' => 'Pregled',
+    'pages_md_insert_image' => 'Umetni sliku',
+    'pages_md_insert_link' => 'Umetni poveznicu',
+    'pages_md_insert_drawing' => 'Umetni crtež',
+    'pages_not_in_chapter' => 'Stranica nije u poglavlju',
+    'pages_move' => 'Premjesti stranicu',
+    'pages_move_success' => 'Stranica premještena u ":parentName"',
+    'pages_copy' => 'Kopiraj stranicu',
+    'pages_copy_desination' => 'Kopiraj odredište',
+    'pages_copy_success' => 'Stranica je uspješno kopirana',
+    'pages_permissions' => 'Dopuštenja stranice',
+    'pages_permissions_success' => 'Ažurirana dopuštenja stranice',
+    'pages_revision' => 'Revizija',
+    'pages_revisions' => 'Revizija stranice',
+    'pages_revisions_named' => 'Revizije stranice :pageName',
+    'pages_revision_named' => 'Revizija stranice :pageName',
+    'pages_revision_restored_from' => 'Oporavak iz #:id; :summary',
+    'pages_revisions_created_by' => 'Stvoreno od',
+    'pages_revisions_date' => 'Datum revizije',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Revizija #:id',
+    'pages_revisions_numbered_changes' => 'Revizija #:id Promjene',
+    'pages_revisions_changelog' => 'Dnevnik promjena',
+    'pages_revisions_changes' => 'Promjene',
+    'pages_revisions_current' => 'Trenutna verzija',
+    'pages_revisions_preview' => 'Pregled',
+    'pages_revisions_restore' => 'Vrati',
+    'pages_revisions_none' => 'Ova stranica nema revizija',
+    'pages_copy_link' => 'Kopiraj poveznicu',
+    'pages_edit_content_link' => 'Uredi sadržaj',
+    'pages_permissions_active' => 'Aktivna dopuštenja stranice',
+    'pages_initial_revision' => 'Početno objavljivanje',
+    'pages_initial_name' => 'Nova stranica',
+    'pages_editing_draft_notification' => 'Uređujete nacrt stranice posljednji put spremljen :timeDiff.',
+    'pages_draft_edited_notification' => 'Ova je stranica u međuvremenu ažurirana. Preporučujemo da odbacite ovaj nacrt.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count korisnika koji uređuju ovu stranicu',
+        'start_b' => ':userName je počeo uređivati ovu stranicu',
+        'time_a' => 'otkad je stranica posljednji put ažurirana',
+        'time_b' => 'u zadnjih :minCount minuta',
+        'message' => ':start :time. Pripazite na uzajamna ažuriranja!',
+    ],
+    'pages_draft_discarded' => 'Nacrt je odbijen jer je uređivač ažurirao postoječi sadržaj',
+    'pages_specific' => 'Predlošci stranice',
+    'pages_is_template' => 'Predložak stranice',
+
+    // Editor Sidebar
+    'page_tags' => 'Oznake stranice',
+    'chapter_tags' => 'Oznake poglavlja',
+    'book_tags' => 'Oznake knjiga',
+    'shelf_tags' => 'Oznake polica',
+    'tag' => 'Oznaka',
+    'tags' =>  'Tags',
+    'tag_name' =>  'Tag Name',
+    'tag_value' => 'Oznaka vrijednosti (neobavezno)',
+    'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
+    'tags_add' => 'Dodaj oznaku',
+    'tags_remove' => 'Makni oznaku',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
+    'attachments' => 'Prilozi',
+    'attachments_explain' => 'Dodajte datoteke ili poveznice za prikaz na vašoj stranici. Vidljive su na rubnoj oznaci stranice.',
+    'attachments_explain_instant_save' => 'Promjene se automatski spremaju.',
+    'attachments_items' => 'Dodane stavke',
+    'attachments_upload' => 'Dodaj datoteku',
+    'attachments_link' => 'Dodaj poveznicu',
+    'attachments_set_link' => 'Postavi poveznicu',
+    'attachments_delete' => 'Jeste li sigurni da želite izbrisati ovu stavku?',
+    'attachments_dropzone' => 'Dodajte datoteke ili kliknite ovdje',
+    'attachments_no_files' => 'Nijedna datoteka nije prenesena',
+    'attachments_explain_link' => 'Možete dodati poveznicu ako ne želite prenijeti datoteku. Poveznica može voditi na drugu stranicu ili datoteku.',
+    'attachments_link_name' => 'Ime poveznice',
+    'attachment_link' => 'Poveznica na privitak',
+    'attachments_link_url' => 'Poveznica na datoteku',
+    'attachments_link_url_hint' => 'Url ili stranica ili datoteka',
+    'attach' => 'Dodaj',
+    'attachments_insert_link' => 'Dodaj poveznicu na stranicu',
+    'attachments_edit_file' => 'Uredi datoteku',
+    'attachments_edit_file_name' => 'Ime datoteke',
+    'attachments_edit_drop_upload' => 'Dodaj datoteku ili klikni ovdje za prijenos',
+    'attachments_order_updated' => 'Ažurirani popis priloga',
+    'attachments_updated_success' => 'Ažurirani detalji priloga',
+    'attachments_deleted' => 'Izbrisani prilozi',
+    'attachments_file_uploaded' => 'Datoteka je uspješno prenešena',
+    'attachments_file_updated' => 'Datoteka je uspješno ažurirana',
+    'attachments_link_attached' => 'Poveznica je dodana na stranicu',
+    'templates' => 'Predlošci',
+    'templates_set_as_template' => 'Stranica je predložak',
+    'templates_explain_set_as_template' => 'Ovu stranicu možete postaviti pomoću predloška koji možete koristiti tijekom stvaranja drugih stranica. Ostali korisnici će ga također moći koristiti ako imaju dopuštenje.',
+    'templates_replace_content' => 'Zamjeni sadržaj stranice',
+    'templates_append_content' => 'Dodaj sadržaju stranice',
+    'templates_prepend_content' => 'Dodaj na sadržaj stranice',
+
+    // Profile View
+    'profile_user_for_x' => 'Korisnik za :time',
+    'profile_created_content' => 'Stvoreni sadržaj',
+    'profile_not_created_pages' => ':userName nije kreirao nijednu stranicu',
+    'profile_not_created_chapters' => ':userName nije kreirao nijedno poglavlje',
+    'profile_not_created_books' => ':userName nije kreirao nijednu knjigu',
+    'profile_not_created_shelves' => ':userName nije kreirao nijednu policu',
+
+    // Comments
+    'comment' => 'Komentar',
+    'comments' => 'Komentari',
+    'comment_add' => 'Dodaj komentar',
+    'comment_placeholder' => 'Komentar ostavi ovdje',
+    'comment_count' => '{0} Nema komentara|{1} 1 Komentar|[2,*] :count Komentara',
+    'comment_save' => 'Spremi komentar',
+    'comment_saving' => 'Spremanje komentara',
+    'comment_deleting' => 'Brisanje komentara',
+    'comment_new' => 'Novi komentar',
+    'comment_created' => 'komentirano :createDiff',
+    'comment_updated' => 'Ažurirano :updateDiff od :username',
+    'comment_deleted_success' => 'Izbrisani komentar',
+    'comment_created_success' => 'Dodani komentar',
+    'comment_updated_success' => 'Ažurirani komentar',
+    'comment_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovaj komentar?',
+    'comment_in_reply_to' => 'Odgovor na :commentId',
+
+    // Revision
+    'revision_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovaj ispravak?',
+    'revision_restore_confirm' => 'Jeste li sigurni da želite vratiti ovaj ispravak? Trenutni sadržaj će biti zamijenjen.',
+    'revision_delete_success' => 'Izbrisani ispravak',
+    'revision_cannot_delete_latest' => 'Posljednji ispravak se ne može izbrisati.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
+];
diff --git a/resources/lang/hr/errors.php b/resources/lang/hr/errors.php
new file mode 100644 (file)
index 0000000..1022ca4
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Nemate dopuštenje za pristup traženoj stranici.',
+    'permissionJson' => 'Nemate potrebno dopuštenje.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Korisnik s mailom :email već postoji, ali s drugom vjerodajnicom.',
+    'email_already_confirmed' => 'Email je već potvrđen, pokušajte se logirati.',
+    'email_confirmation_invalid' => 'Ova vjerodajnica nije valjana ili je već bila korištena. Pokušajte se ponovno registrirati.',
+    'email_confirmation_expired' => 'Ova vjerodajnica je istekla. Poslan je novi email za pristup.',
+    'email_confirmation_awaiting' => 'Email adresa za račun koji se koristi mora biti potvrđen',
+    'ldap_fail_anonymous' => 'LDAP pristup nije uspio zbog anonimnosti',
+    'ldap_fail_authed' => 'LDAP pristup nije uspio',
+    'ldap_extension_not_installed' => 'LDAP PHP ekstenzija nije instalirana',
+    'ldap_cannot_connect' => 'Nemoguće pristupiti ldap serveru, problem s mrežom',
+    'saml_already_logged_in' => 'Već ste prijavljeni',
+    'saml_user_not_registered' => 'Korisnik :name nije registriran i automatska registracija je onemogućena',
+    'saml_no_email_address' => 'Nismo pronašli email adresu za ovog korisnika u vanjskim sustavima',
+    'saml_invalid_response_id' => 'Sustav za autentifikaciju nije prepoznat. Ovaj problem možda je nastao zbog vraćanja nakon prijave.',
+    'saml_fail_authed' => 'Prijava pomoću :system nije uspjela zbog neuspješne autorizacije',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Nije definirana nijedna radnja',
+    'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
+    'social_account_in_use' => 'Ovaj :socialAccount račun se već koristi. Pokušajte se prijaviti pomoću :socialAccount računa.',
+    'social_account_email_in_use' => 'Ovaj mail :email se već koristi. Ako već imate naš račun možete se prijaviti pomoću :socialAccount računa u postavkama vašeg profila.',
+    'social_account_existing' => 'Ovaj :socialAccount je već dodan u vaš profil.',
+    'social_account_already_used_existing' => 'Ovaj :socialAccount već koristi drugi korisnik.',
+    'social_account_not_used' => 'Ovaj :socialAccount račun ne koristi nijedan korisnik. Dodajte ga u postavke svog profila.',
+    'social_account_register_instructions' => 'Ako nemate račun možete se registrirati pomoću :socialAccount opcija.',
+    'social_driver_not_found' => 'Nije pronađeno',
+    'social_driver_not_configured' => 'Postavke vašeg :socialAccount računa nisu ispravno postavljene.',
+    'invite_token_expired' => 'Vaša pozivnica je istekla. Pokušajte ponovno postaviti lozinku.',
+
+    // System
+    'path_not_writable' => 'Datoteka :filePath ne može se prenijeti. Učinite je lakše prepoznatljivom vašem serveru.',
+    'cannot_get_image_from_url' => 'Nemoguće preuzeti sliku sa :url',
+    'cannot_create_thumbs' => 'Provjerite imate li instaliranu GD PHP ekstenziju.',
+    'server_upload_limit' => 'Prevelika količina za server. Pokušajte prenijeti manju veličinu.',
+    'uploaded'  => 'Prevelika količina za server. Pokušajte prenijeti manju veličinu.',
+    'image_upload_error' => 'Problem s prenosom slike',
+    'image_upload_type_error' => 'Nepodržani format slike',
+    'file_upload_timeout' => 'Isteklo vrijeme za prijenos datoteke.',
+
+    // Attachments
+    'attachment_not_found' => 'Prilozi nisu pronađeni',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Problem sa spremanjem nacrta. Osigurajte stabilnu internetsku vezu.',
+    'page_custom_home_deletion' => 'Stranica označena kao naslovnica ne može se izbrisati',
+
+    // Entities
+    'entity_not_found' => 'Nije pronađeno',
+    'bookshelf_not_found' => 'Polica nije pronađena',
+    'book_not_found' => 'Knjiga nije pronađena',
+    'page_not_found' => 'Stranica nije pronađena',
+    'chapter_not_found' => 'Poglavlje nije pronađeno',
+    'selected_book_not_found' => 'Odabrana knjiga nije pronađena',
+    'selected_book_chapter_not_found' => 'Odabrane knjige ili poglavlja nisu pronađena',
+    'guests_cannot_save_drafts' => 'Gosti ne mogu spremiti nacrte',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Ne možete izbrisati',
+    'users_cannot_delete_guest' => 'Ne možete izbrisati',
+
+    // Roles
+    'role_cannot_be_edited' => 'Ne može se urediti',
+    'role_system_cannot_be_deleted' => 'Sistemske postavke ne možete izbrisati',
+    'role_registration_default_cannot_delete' => 'Ne može se izbrisati',
+    'role_cannot_remove_only_admin' => 'Učinite drugog korisnika administratorom prije uklanjanja ove administratorske uloge.',
+
+    // Comments
+    'comment_list' => 'Pogreška prilikom dohvaćanja komentara.',
+    'cannot_add_comment_to_draft' => 'Ne možete ostaviti komentar na ovaj nacrt.',
+    'comment_add' => 'Greška prilikom dodavanja ili ažuriranja komentara.',
+    'comment_delete' => 'Greška prilikom brisanja komentara.',
+    'empty_comment' => 'Ne možete ostaviti prazan komentar.',
+
+    // Error pages
+    '404_page_not_found' => 'Stranica nije pronađena',
+    'sorry_page_not_found' => 'Žao nam je, stranica koju tražite nije pronađena.',
+    'sorry_page_not_found_permission_warning' => 'Ako smatrate da ova stranica još postoji, ali je ne vidite, moguće je da nemate omogućen pristup.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Povratak na početno',
+    'error_occurred' => 'Došlo je do pogreške',
+    'app_down' => ':appName trenutno nije dostupna',
+    'back_soon' => 'Uskoro će se vratiti.',
+
+    // API errors
+    'api_no_authorization_found' => 'Nije pronađena autorizacija',
+    'api_bad_authorization_format' => 'Pogreška prilikom autorizacije',
+    'api_user_token_not_found' => 'Format autorizacije nije podržan',
+    'api_incorrect_token_secret' => 'Netočan API token',
+    'api_user_no_api_permission' => 'Vlasnik API tokena nema potrebna dopuštenja',
+    'api_user_token_expired' => 'Autorizacija je istekla',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Pogreška prilikom slanja testnog email:',
+
+];
diff --git a/resources/lang/hr/pagination.php b/resources/lang/hr/pagination.php
new file mode 100644 (file)
index 0000000..1165a10
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Pagination Language Lines
+ * The following language lines are used by the paginator library to build
+ * the simple pagination links.
+ */
+return [
+
+    'previous' => '&laquo; Prethodno',
+    'next'     => 'Sljedeće &raquo;',
+
+];
diff --git a/resources/lang/hr/passwords.php b/resources/lang/hr/passwords.php
new file mode 100644 (file)
index 0000000..51104ab
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Password Reminder Language Lines
+ * The following language lines are the default lines which match reasons
+ * that are given by the password broker for a password update attempt has failed.
+ */
+return [
+
+    'password' => 'Lozinka mora imati najmanje 8 znakova i biti potvrđena.',
+    'user' => "We can't find a user with that e-mail address.",
+    'token' => 'Ponovno postavljanje lozinke nemoguće putem ove adrese.',
+    'sent' => 'Na vašu email adresu poslana je poveznica za ponovno postavljanje!',
+    'reset' => 'Vaša je lozinka ponovno postavljena!',
+
+];
diff --git a/resources/lang/hr/settings.php b/resources/lang/hr/settings.php
new file mode 100644 (file)
index 0000000..553c7e5
--- /dev/null
@@ -0,0 +1,306 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Postavke',
+    'settings_save' => 'Spremi postavke',
+    'settings_save_success' => 'Postavke spremljene',
+
+    // App Settings
+    'app_customization' => 'Prilagođavanje',
+    'app_features_security' => 'Značajke & Sigurnost',
+    'app_name' => 'Ime aplikacije',
+    'app_name_desc' => 'Ime je vidljivo u zaglavlju i svakoj sistemskoj poruci.',
+    'app_name_header' => 'Prikaži ime u zaglavlju',
+    'app_public_access' => 'Javni pristup',
+    'app_public_access_desc' => 'Omogućavanje ove postavke pristup sadržaju imat će svi posjetitelji BookStack čak i ako nisu prijavljeni.',
+    'app_public_access_desc_guest' => 'Javni pristup može se urediti putem opcije "Gost".',
+    'app_public_access_toggle' => 'Dozvoli javni pristup',
+    'app_public_viewing' => 'Dozvoljen javni pristup?',
+    'app_secure_images' => 'Visoka razina sigurnosti prijenosa slika',
+    'app_secure_images_toggle' => 'Omogući visoku sigurnost prijenosa slika',
+    'app_secure_images_desc' => 'Zbog specifične izvedbe sve su slike javne. Osigurajte da indeksi direktorija nisu omogućeni kako bi se spriječio neovlašten pristup.',
+    'app_editor' => 'Uređivač stranice',
+    'app_editor_desc' => 'Odaberite uređivače stranica',
+    'app_custom_html' => 'Prilagođeni HTML sadržaj',
+    'app_custom_html_desc' => 'Sav sadržaj dodan ovdje bit će umetnut na dno <glavne> stranice. To je korisno za stiliziranje i dodavanje analitičkog koda.',
+    'app_custom_html_disabled_notice' => 'Prilagođeni HTML je onemogućen kako bi se osiguralo vraćanje promjena u slučaju kvara.',
+    'app_logo' => 'Logo aplikacije',
+    'app_logo_desc' => 'Slika smije biti najviše 43px u visinu. <br>Velike slike će biti smanjene.',
+    'app_primary_color' => 'Primarna boja aplikacije',
+    'app_primary_color_desc' => 'Postavlja primarnu boju za aplikaciju uključujući natpis, gumbe i veze.',
+    'app_homepage' => 'Glavna stranica aplikacije',
+    'app_homepage_desc' => 'Odaberite prikaz svoje glavne stranice umjesto već zadane. Za odabrane stranice ne vrijede zadana dopuštenja.',
+    'app_homepage_select' => 'Odaberi stranicu',
+    'app_footer_links' => 'Podnožje',
+    'app_footer_links_desc' => 'Odaberite poveznice koje će biti vidljive u podnožju većina stranica čak i na nekima koje ne zahtijevaju prijavu. Na primjer, oznaku "trans::common.privacy_policy" možete koristiti za sistemski definirani prijevod teksta "Politika Privatnosti", a za "Uvjete korištenja" možete koristiti "trans::common.terms_of_service".',
+    'app_footer_links_label' => 'Oznaka veze',
+    'app_footer_links_url' => 'Oznaka URL',
+    'app_footer_links_add' => 'Dodaj vezu na podnožje',
+    'app_disable_comments' => 'Onemogući komentare',
+    'app_disable_comments_toggle' => 'Onemogući komentare',
+    'app_disable_comments_desc' => 'Onemogući komentare za sve stranice u aplikaciji. <br> Postojeći komentari nisu prikazani.',
+
+    // Color settings
+    'content_colors' => 'Boja sadržaja',
+    'content_colors_desc' => 'Postavljanje boja za sve elemente stranice. Preporuča se odabir boja čija je svjetlina slična zadanim bojama.',
+    'bookshelf_color' => 'Boja police',
+    'book_color' => 'Boja knjige',
+    'chapter_color' => 'Boja poglavlja',
+    'page_color' => 'Boja stranice',
+    'page_draft_color' => 'Boja nacrta',
+
+    // Registration Settings
+    'reg_settings' => 'Registracija',
+    'reg_enable' => 'Omogući registraciju',
+    'reg_enable_toggle' => 'Omogući registraciju',
+    'reg_enable_desc' => 'Ako je omogućeno korisnik se može sam registrirati nakon čega će mu biti dodijeljena jedna od korisničkih uloga.',
+    'reg_default_role' => 'Zadaj ulogu korisnika nakon registracije',
+    'reg_enable_external_warning' => 'Gornja opcija se zanemaruje ako postoji LDAP ili SAML autorifikacija. Korisnički računi za nepostojeće članove automatski će se kreirati ako je vanjska provjera autentičnosti bila uspješna.',
+    'reg_email_confirmation' => 'Potvrda e maila',
+    'reg_email_confirmation_toggle' => 'Zahtjev za potvrdom e maila',
+    'reg_confirm_email_desc' => 'Ako postoje ograničenja domene potvrda e maila će se zahtijevati i ova će se opcija zanemariti.',
+    'reg_confirm_restrict_domain' => 'Ograničenja domene',
+    'reg_confirm_restrict_domain_desc' => 'Unesite popis email domena kojima želite ograničiti registraciju i odvojite ih zarezom. Korisnicima će se slati email prije interakcije s aplikacijom. <br> Uzmite u obzir da će korisnici moći koristiti druge e mail adrese nakon uspješne registracije.',
+    'reg_confirm_restrict_domain_placeholder' => 'Bez ograničenja',
+
+    // Maintenance settings
+    'maint' => 'Održavanje',
+    'maint_image_cleanup' => 'Čišćenje slika',
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
+    'maint_delete_images_only_in_revisions' => 'Izbriši slike koje postoje u prijašnjim revizijama',
+    'maint_image_cleanup_run' => 'Pokreni čišćenje',
+    'maint_image_cleanup_warning' => ':count moguće neiskorištene slike. Jeste li sigurni da želite izbrisati ove slike?',
+    'maint_image_cleanup_success' => ':count moguće neiskorištene slike su pronađene i izbrisane!',
+    'maint_image_cleanup_nothing_found' => 'Nema neiskorištenih slika, Ništa nije izbrisano!',
+    'maint_send_test_email' => 'Pošalji testni Email',
+    'maint_send_test_email_desc' => 'Na ovaj način šaljete testni Email na adresu navedenu u vašem profilu.',
+    'maint_send_test_email_run' => 'Pošalji testni email',
+    'maint_send_test_email_success' => 'Email je poslan na :address',
+    'maint_send_test_email_mail_subject' => 'Testni email',
+    'maint_send_test_email_mail_greeting' => 'Email se može koristiti!',
+    'maint_send_test_email_mail_text' => 'Čestitamo! Ako ste primili ovaj e mail znači da ćete ga moći koristiti.',
+    'maint_recycle_bin_desc' => 'Izbrisane police, knjige, poglavlja i stranice poslane su u Recycle bin i mogu biti vraćene ili trajno izbrisane. Starije stavke bit će automatski izbrisane nakon nekog vremena što ovisi o konfiguraciji sustava.',
+    'maint_recycle_bin_open' => 'Otvori Recycle Bin',
+
+    // Recycle Bin
+    'recycle_bin' => 'Recycle Bin',
+    'recycle_bin_desc' => 'Ovdje možete vratiti izbrisane stavke ili ih trajno ukloniti iz sustava. Popis nije filtriran kao što su to popisi u kojima su omogućeni filteri.',
+    'recycle_bin_deleted_item' => 'Izbrisane stavke',
+    'recycle_bin_deleted_parent' => 'Parent',
+    'recycle_bin_deleted_by' => 'Izbrisano od',
+    'recycle_bin_deleted_at' => 'Vrijeme brisanja',
+    'recycle_bin_permanently_delete' => 'Trajno izbrisano',
+    'recycle_bin_restore' => 'Vrati',
+    'recycle_bin_contents_empty' => 'Recycle Bin je prazan',
+    'recycle_bin_empty' => 'Isprazni Recycle Bin',
+    'recycle_bin_empty_confirm' => 'Ovo će trajno obrisati sve stavke u Recycle Bin i sadržaje povezane s njima. Jeste li sigurni da želite isprazniti Recycle Bin?',
+    'recycle_bin_destroy_confirm' => 'Ovom radnjom ćete trajno izbrisati ovu stavku i nećete je više moći vratiti. Želite li je trajno izbrisati?',
+    'recycle_bin_destroy_list' => 'Stavke koje treba izbrisati',
+    'recycle_bin_restore_list' => 'Stavke koje treba vratiti',
+    'recycle_bin_restore_confirm' => 'Ova radnja vraća izbrisane stavke i njene podređene elemente na prvobitnu lokaciju. Ako je nadređena stavka izbrisana i nju treba vratiti.',
+    'recycle_bin_restore_deleted_parent' => 'S obzirom da je nadređena stavka obrisana najprije treba vratiti nju.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'Ukupno izbrisane :count stavke iz Recycle Bin',
+    'recycle_bin_restore_notification' => 'Ukupno vraćene :count stavke iz Recycle Bin',
+
+    // Audit Log
+    'audit' => 'Dnevnik revizije',
+    'audit_desc' => 'Ovaj dnevnik revizije prikazuje popis aktivnosti zabilježene u sustavu. Ovaj popis nije definiran budući da nisu postavljeni filteri.',
+    'audit_event_filter' => 'Filter događaja',
+    'audit_event_filter_no_filter' => 'Bez filtera',
+    'audit_deleted_item' => 'Izbrisane stavke',
+    'audit_deleted_item_name' => 'Ime: :name',
+    'audit_table_user' => 'Korisnik',
+    'audit_table_event' => 'Događaj',
+    'audit_table_related' => 'Povezana stavka ili detalj',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Datum aktivnosti',
+    'audit_date_from' => 'Rangiraj datum od',
+    'audit_date_to' => 'Rangiraj datum do',
+
+    // Role Settings
+    'roles' => 'Uloge',
+    'role_user_roles' => 'Uloge korisnika',
+    'role_create' => 'Stvori novu ulogu',
+    'role_create_success' => 'Uloga uspješno stvorena',
+    'role_delete' => 'Izbriši ulogu',
+    'role_delete_confirm' => 'Ovo će izbrisati ulogu povezanu s imenom \':roleName\'.',
+    'role_delete_users_assigned' => 'Ova uloga dodijeljena je :userCount. Ako želite premjestiti korisnike iz ove uloge odaberite novu ulogu u nastavku.',
+    'role_delete_no_migration' => "Don't migrate users",
+    'role_delete_sure' => 'Jeste li sigurni da želite obrisati ovu ulogu?',
+    'role_delete_success' => 'Uloga je uspješno izbrisana',
+    'role_edit' => 'Uredi ulogu',
+    'role_details' => 'Detalji uloge',
+    'role_name' => 'Ime uloge',
+    'role_desc' => 'Kratki opis uloge',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_external_auth_id' => 'Autorizacija',
+    'role_system' => 'Dopuštenja sustava',
+    'role_manage_users' => 'Upravljanje korisnicima',
+    'role_manage_roles' => 'Upravljanje ulogama i dopuštenjima',
+    'role_manage_entity_permissions' => 'Upravljanje dopuštenjima nad knjigama, poglavljima i stranicama',
+    'role_manage_own_entity_permissions' => 'Upravljanje dopuštenjima vlastitih knjiga, poglavlja i stranica',
+    'role_manage_page_templates' => 'Upravljanje predlošcima stranica',
+    'role_access_api' => 'API pristup',
+    'role_manage_settings' => 'Upravljanje postavkama aplikacija',
+    'role_export_content' => 'Export content',
+    '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_all' => 'Sve',
+    'role_own' => 'Vlastito',
+    'role_controlled_by_asset' => 'Kontrolirano od strane vlasnika',
+    'role_save' => 'Spremi ulogu',
+    'role_update_success' => 'Uloga uspješno ažurirana',
+    'role_users' => 'Korisnici u ovoj ulozi',
+    'role_users_none' => 'Trenutno nijedan korisnik nije u ovoj ulozi',
+
+    // Users
+    'users' => 'Korisnici',
+    'user_profile' => 'Profil korisnika',
+    'users_add_new' => 'Dodajte novog korisnika',
+    'users_search' => 'Pretražite korisnike',
+    'users_latest_activity' => 'Zadnje aktivnosti',
+    'users_details' => 'Detalji korisnika',
+    'users_details_desc' => 'Postavite prikaz imena i email adrese za ovog korisnika. Email adresa koristit će se za prijavu u aplikaciju.',
+    'users_details_desc_no_email' => 'Postavite prikaz imena ovog korisnika da ga drugi mogu prepoznati.',
+    'users_role' => 'Uloge korisnika',
+    'users_role_desc' => 'Odaberite koje će uloge biti dodijeljene ovom korisniku. Ako korisnik ima više uloga njihova će se dopuštenja prilagoditi.',
+    'users_password' => 'Lozinka korisnika',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
+    'users_send_invite_text' => 'Možete odabrati slanje e maila korisniku i dozvoliti mu da postavi svoju lozinku ili vi to možete učiniti za njega.',
+    'users_send_invite_option' => 'Pošaljite pozivnicu korisniku putem emaila',
+    'users_external_auth_id' => 'Vanjska autorizacija',
+    'users_external_auth_id_desc' => 'Ovaj ID koristi se za komunikaciju s vanjskim sustavom za autorizaciju.',
+    'users_password_warning' => 'Ispunite dolje samo ako želite promijeniti lozinku.',
+    'users_system_public' => 'Ovaj korisnik predstavlja bilo kojeg gosta. Dodjeljuje se automatski.',
+    'users_delete' => 'Izbrišite korisnika',
+    'users_delete_named' => 'Izbrišite korisnika :userName',
+    'users_delete_warning' => 'Ovo će ukloniti korisnika \':userName\' iz sustava.',
+    'users_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovog korisnika?',
+    'users_migrate_ownership' => 'Premjestite vlasništvo',
+    'users_migrate_ownership_desc' => 'Ovdje odaberite korisnika kojem ćete dodijeliti vlasništvo i sve stavke povezane s njim.',
+    'users_none_selected' => 'Nije odabran nijedan korisnik',
+    'users_delete_success' => 'Korisnik je uspješno premješten',
+    'users_edit' => 'Uredite korisnika',
+    'users_edit_profile' => 'Uredite profil',
+    'users_edit_success' => 'Korisnik je uspješno ažuriran',
+    'users_avatar' => 'Korisnički avatar',
+    'users_avatar_desc' => 'Odaberite sliku koja će predstavljati korisnika. Maksimalno 256px.',
+    'users_preferred_language' => 'Prioritetni jezik',
+    'users_preferred_language_desc' => 'Ova će opcija promijeniti jezik korisničkog sučelja. Neće utjecati na sadržaj.',
+    'users_social_accounts' => 'Računi društvenih mreža',
+    'users_social_accounts_info' => 'Ovdje možete povezati račun s onim na društvenim mrežama za bržu i lakšu prijavu. Ako se odspojite ovdje to neće utjecati na prethodnu autorizaciju. Na postavkama računa vaše društvene mreže možete opozvati pristup.',
+    'users_social_connect' => 'Poveži račun',
+    'users_social_disconnect' => 'Odvoji račun',
+    'users_social_connected' => ':socialAccount račun je uspješno dodan vašem profilu.',
+    'users_social_disconnected' => ':socialAccount račun je uspješno odvojen od vašeg profila.',
+    'users_api_tokens' => 'API tokeni',
+    'users_api_tokens_none' => 'Nijedan API token nije stvoren za ovog korisnika',
+    'users_api_tokens_create' => 'Stvori token',
+    'users_api_tokens_expires' => 'Isteklo',
+    'users_api_tokens_docs' => 'API dokumentacija',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
+
+    // API Tokens
+    'user_api_token_create' => 'Stvori API token',
+    'user_api_token_name' => 'Ime',
+    'user_api_token_name_desc' => 'Imenujte svoj token na način da prepoznate njegovu svrhu.',
+    'user_api_token_expiry' => 'Datum isteka',
+    'user_api_token_expiry_desc' => 'Postavite datum kada token istječe. Ostavljanjem ovog polja praznim automatski se postavlja dugoročno razdoblje.',
+    'user_api_token_create_secret_message' => 'Odmah nakon kreiranja tokena prikazat će se "Token ID" i "Token Secret". To će se prikazati samo jednom i zato preporučujemo da ga spremite na sigurno.',
+    'user_api_token_create_success' => 'API token uspješno kreiran',
+    'user_api_token_update_success' => 'API token uspješno ažuriran',
+    'user_api_token' => 'API token',
+    'user_api_token_id' => 'Token ID',
+    'user_api_token_id_desc' => 'Ovaj sistemski generiran identifikator ne može se uređivati i bit će potreban pri API zahtjevima.',
+    'user_api_token_secret' => 'Token Secret',
+    'user_api_token_secret_desc' => 'Ovaj sistemski generirani Token Secret trebat ćete za API zahtjev. Prikazuje se samo prvi put i zato ga spremite na sigurno.',
+    'user_api_token_created' => 'Token kreiran :timeAgo',
+    'user_api_token_updated' => 'Token ažuriran :timeAgo',
+    'user_api_token_delete' => 'Izbriši token',
+    'user_api_token_delete_warning' => 'Ovo će potpuno izbrisati API token naziva \':tokenName\' iz našeg sustava.',
+    'user_api_token_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovaj API token?',
+    'user_api_token_delete_success' => 'API token uspješno izbrisan',
+
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
+    //! If editing translations files directly please ignore this in all
+    //! languages apart from en. Content will be auto-copied from en.
+    //!////////////////////////////////
+    'language_select' => [
+        'en' => 'English',
+        'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
+        'cs' => 'Česky',
+        'da' => 'Dansk',
+        'de' => 'Deutsch (Sie)',
+        'de_informal' => 'Deutsch (Du)',
+        'es' => 'Español',
+        'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
+        'fr' => 'Français',
+        'he' => 'עברית',
+        'hr' => 'Hrvatski',
+        'hu' => 'Magyar',
+        'id' => 'Bahasa Indonesia',
+        'it' => 'Italian',
+        'ja' => '日本語',
+        'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
+        'lv' => 'Latviešu Valoda',
+        'nl' => 'Nederlands',
+        'nb' => 'Norsk (Bokmål)',
+        'pl' => 'Polski',
+        'pt' => 'Português',
+        'pt_BR' => 'Português do Brasil',
+        'ru' => 'Русский',
+        'sk' => 'Slovensky',
+        'sl' => 'Slovenščina',
+        'sv' => 'Svenska',
+        'tr' => 'Türkçe',
+        'uk' => 'Українська',
+        'vi' => 'Tiếng Việt',
+        'zh_CN' => '简体中文',
+        'zh_TW' => '繁體中文',
+    ],
+    //!////////////////////////////////
+];
diff --git a/resources/lang/hr/validation.php b/resources/lang/hr/validation.php
new file mode 100644 (file)
index 0000000..b88e872
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Validation Lines
+ * The following language lines contain the default error messages used by
+ * the validator class. Some of these rules have multiple versions such
+ * as the size rules. Feel free to tweak each of these messages here.
+ */
+return [
+
+    // Standard laravel validation lines
+    'accepted'             => ':attribute mora biti prihvaćen.',
+    'active_url'           => ':attribute nema valjan URL.',
+    'after'                => ':attribute mora biti nakon :date.',
+    'alpha'                => ':attribute može sadržavati samo slova.',
+    'alpha_dash'           => ':attribute  može sadržavati samo slova, brojeve, crtice i donje crtice.',
+    'alpha_num'            => ':attribute može sadržavati samo slova i brojeve.',
+    'array'                => ':attribute mora biti niz.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'before'               => ':attribute mora biti prije :date.',
+    'between'              => [
+        'numeric' => ':attribute mora biti između :min i :max.',
+        'file'    => ':attribute mora biti između :min i :max kilobajta.',
+        'string'  => ':attribute mora biti između :min i :max znakova.',
+        'array'   => ':attribute mora biti između :min i :max stavki',
+    ],
+    'boolean'              => ':attribute mora biti točno ili netočno.',
+    'confirmed'            => ':attribute potvrde se ne podudaraju.',
+    'date'                 => ':attribute nema valjani datum.',
+    'date_format'          => ':attribute ne odgovara formatu :format.',
+    'different'            => ':attribute i :other se moraju razlikovati.',
+    'digits'               => ':attribute mora biti :digits znakova.',
+    'digits_between'       => ':attribute mora biti između :min i :max znamenki.',
+    'email'                => ':attribute mora biti valjana email adresa.',
+    'ends_with' => ':attribute mora završiti s :values',
+    'filled'               => ':attribute polje je obavezno.',
+    'gt'                   => [
+        'numeric' => ':attribute mora biti veći od :value.',
+        'file'    => ':attribute mora biti veći od :value  kilobajta.',
+        'string'  => ':attribute mora biti veći od :value znakova',
+        'array'   => ':attribute mora biti veći od :value stavki.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute mora biti veći ili jednak :value.',
+        'file'    => ':attribute mora biti veći ili jednak :value kilobajta.',
+        'string'  => ':attribute mora biti veći ili jednak :value znakova.',
+        'array'   => ':attribute mora imati :value stavki ili više.',
+    ],
+    'exists'               => 'Odabrani :attribute ne vrijedi.',
+    'image'                => ':attribute mora biti slika.',
+    'image_extension'      => ':attribute mora imati valjanu i podržanu ekstenziju.',
+    'in'                   => 'Odabrani :attribute ne vrijedi.',
+    'integer'              => ':attribute mora biti cijeli broj.',
+    'ip'                   => ':attribute mora biti valjana IP adresa.',
+    'ipv4'                 => ':attribute mora biti valjana IPv4 adresa.',
+    'ipv6'                 => ':attribute mora biti valjana IPv6 adresa.',
+    'json'                 => ':attribute mora biti valjani JSON niz.',
+    'lt'                   => [
+        'numeric' => ':attribute mora biti manji od :value.',
+        'file'    => ':attribute mora biti manji od :value kilobajta.',
+        'string'  => ':attribute mora biti manji od :value znakova.',
+        'array'   => ':attribute mora biti manji od :value stavki.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute mora biti manji ili jednak :value.',
+        'file'    => ':attribute mora biti manji ili jednak :value kilobajta.',
+        'string'  => ':attribute mora biti manji ili jednak :value znakova.',
+        'array'   => ':attribute mora imati više od :value stavki.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute ne smije biti veći od :max.',
+        'file'    => ':attribute ne smije biti veći od :max kilobajta.',
+        'string'  => ':attribute ne smije biti duži od :max znakova.',
+        'array'   => ':attribute ne smije imati više od :max stavki.',
+    ],
+    'mimes'                => ':attribute mora biti datoteka tipa: :values.',
+    'min'                  => [
+        'numeric' => ':attribute mora biti najmanje :min.',
+        'file'    => ':attribute mora imati najmanje :min kilobajta.',
+        'string'  => ':attribute mora imati najmanje :min znakova.',
+        'array'   => ':attribute mora imati najmanje :min stavki.',
+    ],
+    'not_in'               => 'Odabrani :attribute ne vrijedi.',
+    'not_regex'            => 'Format :attribute nije valjan.',
+    'numeric'              => ':attribute mora biti broj.',
+    'regex'                => 'Format :attribute nije valjan.',
+    'required'             => ':attribute polje je obavezno.',
+    'required_if'          => 'Polje :attribute je obavezno kada :other je :value.',
+    'required_with'        => 'Polje :attribute je potrebno kada :values je sadašnjost.',
+    'required_with_all'    => 'Polje :attribute je potrebno kada :values je sadašnjost.',
+    'required_without'     => 'Polje :attribute je potrebno kada :values nije sadašnjost.',
+    'required_without_all' => 'Polje :attribute je potrebno kada ništa od :values nije sadašnjost.',
+    'same'                 => ':attribute i :other se moraju podudarati.',
+    'safe_url'             => 'Navedena veza možda nije sigurna.',
+    'size'                 => [
+        'numeric' => ':attribute mora biti :size.',
+        'file'    => ':attribute mora biti :size kilobajta.',
+        'string'  => ':attribute mora biti :size znakova.',
+        'array'   => ':attribute mora sadržavati :size stavki.',
+    ],
+    'string'               => ':attribute mora biti niz.',
+    'timezone'             => ':attribute mora biti valjan.',
+    'totp'                 => 'The provided code is not valid or has expired.',
+    'unique'               => ':attribute se već koristi.',
+    'url'                  => 'Format :attribute nije valjan.',
+    'uploaded'             => 'Datoteka se ne može prenijeti. Server možda ne prihvaća datoteke te veličine.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Potrebna potvrda lozinke',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index 8d22605e36c1c0549ebc99f83866163d44296c7b..1a34acbd4b4a743ed311c3fa3e5d3c9f1a83322f 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'létrehozta az oldalt:',
-    'page_create_notification'    => 'Oldal sikeresen létrehozva',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'frissítette az oldalt:',
-    'page_update_notification'    => 'Oldal sikeresen frissítve',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'törölte az oldalt:',
-    'page_delete_notification'    => 'Oldal sikeresen törölve',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'visszaállította az oldalt:',
-    'page_restore_notification'   => 'Oldal sikeresen visszaállítva',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'áthelyezte az oldalt:',
 
     // Chapters
     'chapter_create'              => 'létrehozta a fejezetet:',
-    'chapter_create_notification' => 'Fejezet sikeresen létrehozva',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'frissítette a fejezetet:',
-    'chapter_update_notification' => 'Fejezet sikeresen frissítve',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'törölte a fejezetet:',
-    'chapter_delete_notification' => 'Fejezet sikeresen törölve',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'áthelyezte a fejezetet:',
 
     // Books
     'book_create'                 => 'létrehozott egy könyvet:',
-    'book_create_notification'    => 'Könyv sikeresen létrehozva',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'frissítette a könyvet:',
-    'book_update_notification'    => 'Könyv sikeresen frissítve',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'törölte a könyvet:',
-    'book_delete_notification'    => 'Könyv sikeresen törölve',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'átrendezte a könyvet:',
-    'book_sort_notification'      => 'Könyv sikeresen újrarendezve',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'létrehozta a könyvespolcot:',
-    'bookshelf_create_notification'    => 'Könyvespolc sikeresen létrehozva',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'frissítette a könyvespolcot:',
-    'bookshelf_update_notification'    => 'Könyvespolc sikeresen frissítve',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'törölte a könyvespolcot:',
-    'bookshelf_delete_notification'    => 'Könyvespolc sikeresen törölve',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'megjegyzést fűzött hozzá:',
index a13c8300e2084722c9cd4f623c9bf08e75ed8b3d..0069e552e05dec834c7d17dcf876fc0e96a1d9f2 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Email',
     'password' => 'Jelszó',
     'password_confirm' => 'Jelszó megerősítése',
-    'password_hint' => 'Négy karakternél hosszabbnak kell lennie',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Elfelejtett jelszó?',
     'remember_me' => 'Emlékezzen rám',
     'ldap_email_hint' => 'A fiókhoz használt email cím megadása.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Ebből az email tartományról nem lehet hozzáférni ehhez az alkalmazáshoz',
     'register_success' => 'Köszönjük a regisztrációt! A regisztráció és a bejelentkezés megtörtént.',
 
-
     // Password Reset
     'reset_password' => 'Jelszó visszaállítása',
     'reset_password_send_instructions' => 'Meg kell adni az email címet amire egy jelszó visszaállító hivatkozás lesz elküldve.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Ezt az emailt azért küldtük mert egy jelszó visszaállításra vonatkozó kérést kaptunk ebből a fiókból.',
     'email_reset_not_requested' => 'Ha nem történt jelszó visszaállításra vonatkozó kérés, akkor nincs szükség további intézkedésre.',
 
-
     // Email Confirmation
     'email_confirm_subject' => ':appName alkalmazásban beállított email címet meg kell erősíteni',
     'email_confirm_greeting' => ':appName köszöni a csatlakozást!',
     'email_confirm_text' => 'Az email címet a lenti gombra kattintva lehet megerősíteni:',
     'email_confirm_action' => 'Email megerősítése',
     'email_confirm_send_error' => 'Az email megerősítés kötelező, de a rendszer nem tudta elküldeni az emailt. Fel kell venni a kapcsolatot az adminisztrátorral és meg kell győződni róla, hogy az email beállítások megfelelőek.',
-    'email_confirm_success' => 'Az email cím megerősítve!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Megerősítő email újraküldve. Ellenőrizni kell a bejövő üzeneteket.',
 
     'email_not_confirmed' => 'Az email cím nincs megerősítve',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => ':appName üdvözöl!',
     'user_invite_page_text' => 'A fiók véglegesítéséhez és a hozzáféréshez be kell állítani egy jelszót ami :appName weboldalon lesz használva a bejelentkezéshez.',
     'user_invite_page_confirm_button' => 'Jelszó megerősítése',
-    'user_invite_success' => 'Jelszó beállítva, :appName most már elérhető!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index 116f57527edff31e614ff615200b7001371d800a..57c8c3f1266681e7d78b3290b4cbae9c82b998bf 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Szerepkör',
     'cover_image' => 'Borítókép',
     'cover_image_description' => 'A kép méretének kb. 440x250px-nek kell lennie.',
-    
+
     // Actions
     'actions' => 'Műveletek',
     'view' => 'Megtekintés',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Visszaállítás',
     'remove' => 'Eltávolítás',
     'add' => 'Hozzáadás',
+    'configure' => 'Configure',
     'fullscreen' => 'Teljes képernyő',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Rendezési beállítások',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Nincs megjeleníthető aktivitás',
     'no_items' => 'Nincsenek elérhető elemek',
     'back_to_top' => 'Oldal eleje',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Részletek átkapcsolása',
     'toggle_thumbnails' => 'Bélyegképek átkapcsolása',
     'details' => 'Részletek',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Lista nézet',
     'default' => 'Alapértelmezés szerinti',
     'breadcrumb' => 'Morzsa',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index 293e4ebc455c8713a4ac49d272e2b10a3f2e4179..8ed1862c79bb7f5543c56ceab1979e37d2ea727c 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Képek',
     'my_recent_drafts' => 'Legutóbbi vázlataim',
     'my_recently_viewed' => 'Általam legutóbb megtekintett',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'Még nincsenek általam megtekintett oldalak',
     'no_pages_recently_created' => 'Nincsenek legutóbb létrehozott oldalak',
     'no_pages_recently_updated' => 'Nincsenek legutóbb frissített oldalak',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Webfájlt tartalmaz',
     'export_pdf' => 'PDF fájl',
     'export_text' => 'Egyszerű szövegfájl',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Jogosultságok',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Könyvespolc jogosultság',
     'shelves_permissions_updated' => 'Könyvespolc jogosultságok frissítve',
     'shelves_permissions_active' => 'Könyvespolc jogosultságok aktívak',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Jogosultság másolása könyvekre',
     'shelves_copy_permissions' => 'Jogosultság másolása',
     'shelves_copy_permissions_explain' => 'Ez alkalmazni fogja ennek a könyvespolcnak az aktuális jogosultság beállításait az összes benne található könyvön. Aktiválás előtt ellenőrizni kell, hogy a könyvespolc jogosultságain végzett összes módosítás el lett mentve.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Fejezetek hátul',
     'books_sort_show_other' => 'Egyéb könyvek mutatása',
     'books_sort_save' => 'Új elrendezés mentése',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Fejezet',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Fejezet áthelyezése',
     'chapters_move_named' => ':chapterName fejezet áthelyezése',
     'chapter_move_success' => 'Fejezet áthelyezve :bookName könyvbe',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Fejezet jogosultságok',
     'chapters_empty' => 'Jelenleg nincsenek oldalak ebben a fejezetben.',
     'chapters_permissions_active' => 'Fejezet jogosultságok aktívak',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Új oldal',
     'pages_editing_draft_notification' => 'A jelenleg szerkesztett vázlat legutóbb ekkor volt elmentve: :timeDiff.',
     'pages_draft_edited_notification' => 'Ezt az oldalt azóta már frissítették. Javasolt ennek a vázlatnak az elvetése.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count felhasználók kezdte el szerkeszteni ezt az oldalt',
         'start_b' => ':userName elkezdte szerkeszteni ezt az oldalt',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Címkék hozzáadása a tartalom jobb kategorizálásához.\nA mélyebb szervezettség megvalósításához hozzá lehet rendelni egy értéket a címkéhez.",
     'tags_add' => 'Másik címke hozzáadása',
     'tags_remove' => 'Címke eltávolítása',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Csatolmányok',
     'attachments_explain' => 'Az oldalon megjelenő fájlok feltöltése vagy hivatkozások csatolása. Az oldal oldalsávjában fognak megjelenni.',
     'attachments_explain_instant_save' => 'Az itt történt módosítások azonnal el lesznek mentve.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Biztosan törölhető ez a változat?',
     'revision_restore_confirm' => 'Biztosan visszaállítható ez a változat? A oldal jelenlegi tartalma le lesz cserélve.',
     'revision_delete_success' => 'Változat törölve',
-    'revision_cannot_delete_latest' => 'A legutolsó változat nem törölhető.'
+    'revision_cannot_delete_latest' => 'A legutolsó változat nem törölhető.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 64229a19f3d3283025093b3c2a159890c82bcb21..84baf5062284a5b8be4ff3e502576bf5db704719 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Ehhez a felhasználóhoz nem található email cím a külső hitelesítő rendszer által átadott adatokban',
     'saml_invalid_response_id' => 'A külső hitelesítő rendszerből érkező kérést nem ismerte fel az alkalmazás által indított folyamat. Bejelentkezés után az előző oldalra történő visszalépés okozhatja ezt a hibát.',
     'saml_fail_authed' => 'Bejelentkezés :system használatával sikertelen, a rendszer nem biztosított sikeres hitelesítést',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Nincs művelet meghatározva',
     'social_login_bad_response' => "Hiba történt :socialAccount bejelentkezés közben:\n:error",
     'social_account_in_use' => ':socialAccount fiók már használatban van. :socialAccount opción keresztül érdemes megpróbálni a bejelentkezést.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Oldal nem található',
     'sorry_page_not_found' => 'Sajnáljuk, a keresett oldal nem található.',
     'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Vissza a kezdőlapra',
     'error_occurred' => 'Hiba örtént',
     'app_down' => ':appName jelenleg nem üzemel',
index ba748aafedd5a51da97fcb60cf6d070333e3f430..9686252aa89c30ef35283faebbddf726536ab09e 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Karbantartás',
     'maint_image_cleanup' => 'Képek tisztítása',
-    'maint_image_cleanup_desc' => "Végigolvassa az oldalakat és a tartalmak változatait, hogy leellenőrizze jelenleg mely képek és rajzok vannak használatban, és mely képek szerepelnek többször. A futtatása előtt feltétlen készíteni kell egy teljes adatbázis és lemezkép mentést.",
+    'maint_image_cleanup_desc' => 'Végigolvassa az oldalakat és a tartalmak változatait, hogy leellenőrizze jelenleg mely képek és rajzok vannak használatban, és mely képek szerepelnek többször. A futtatása előtt feltétlen készíteni kell egy teljes adatbázis és lemezkép mentést.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => 'Tisztítás futtatása',
     'maint_image_cleanup_warning' => ':count potenciálisan nem használt képet találtam. Biztosan törölhetőek ezek a képek?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Lomtár',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
     'recycle_bin_deleted_item' => 'Törölt elem',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Törölte',
     'recycle_bin_deleted_at' => 'Törlés ideje',
     'recycle_bin_permanently_delete' => 'Permanently Delete',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items to be Restored',
     'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Felhasználó',
     'audit_table_event' => 'Esemény',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activity Date',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Szerepkör részletei',
     'role_name' => 'Szerepkör neve',
     'role_desc' => 'Szerepkör rövid leírása',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Külső hitelesítés azonosítók',
     'role_system' => 'Rendszer jogosultságok',
     'role_manage_users' => 'Felhasználók kezelése',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Oldalsablonok kezelése',
     'role_access_api' => 'Hozzáférés a rendszer API-hoz',
     'role_manage_settings' => 'Alkalmazás beállításainak kezelése',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Eszköz jogosultságok',
     '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' => 'Ezek a jogosultság vezérlik a 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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Felhasználói szerepkörök',
     'users_role_desc' => 'A felhasználó melyik szerepkörhöz lesz rendelve. Ha a felhasználó több szerepkörhöz van rendelve, akkor ezeknek a szerepköröknek a jogosultságai összeadódnak, és a a felhasználó a hozzárendelt szerepkörök minden képességét megkapja.',
     'users_password' => 'Felhasználó jelszava',
-    'users_password_desc' => 'Az alkalmazásba bejelentkezéshez használható jelszó beállítása. Legalább 5 karakter hosszúnak kell lennie.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Lehetséges egy meghívó emailt küldeni ennek a felhasználónak ami lehetővé teszi, hogy beállíthassa a saját jelszavát. Máskülönben a jelszót az erre jogosult felhasználónak kell beállítania.',
     'users_send_invite_option' => 'Felhasználó meghívó levél küldése',
     'users_external_auth_id' => 'Külső hitelesítés azonosítója',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Vezérjel létrehozása',
     'users_api_tokens_expires' => 'Lejárat',
     'users_api_tokens_docs' => 'API dokumentáció',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'API vezérjel létrehozása',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Biztosan törölhető ez az API vezérjel?',
     'user_api_token_delete_success' => 'API vezérjel sikeresen törölve',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index d82e3263274961cdce7bfd5aac33e5d7644c935d..3d24cec3ca7ac9c625d3072e43646059b1e9051a 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute csak betűket, számokat és kötőjeleket tartalmazhat.',
     'alpha_num'            => ':attribute csak betűket és számokat tartalmazhat.',
     'array'                => ':attribute tömb kell legyen.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute dátumnak :date előttinek kell lennie.',
     'between'              => [
         'numeric' => ':attribute értékének :min és :max között kell lennie.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute karaktersorozatnak kell legyen.',
     'timezone'             => ':attribute érvényes zóna kell legyen.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute már elkészült.',
     'url'                  => ':attribute formátuma érvénytelen.',
     'uploaded'             => 'A fájlt nem lehet feltölteni. A kiszolgáló nem fogad el ilyen méretű fájlokat.',
index 2269cb2252d5192fd53641f2ca071694868e7291..9871b6e721f39fcb332be83bbcb7bc94883e32a3 100644 (file)
@@ -6,44 +6,60 @@
 return [
 
     // Pages
-    'page_create'                 => 'halaman dibuat',
-    'page_create_notification'    => 'Halaman Berhasil dibuat',
-    'page_update'                 => 'halaman diperbaharui',
-    'page_update_notification'    => 'Berhasil mengupdate halaman',
+    'page_create'                 => 'telah membuat halaman',
+    'page_create_notification'    => 'Page successfully created',
+    'page_update'                 => 'halaman telah diperbaharui',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'halaman dihapus',
-    'page_delete_notification'    => 'Berhasil menghapus halaman',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'halaman telah dipulihkan',
-    'page_restore_notification'   => 'Berhasil memulihkan halaman',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'halaman dipindahkan',
 
     // Chapters
     'chapter_create'              => 'membuat bab',
-    'chapter_create_notification' => 'Bab berhasil dibuat',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'bab diperbaharui',
-    'chapter_update_notification' => 'Bab berhasil diupdate',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'hapus bab',
-    'chapter_delete_notification' => 'Bab berhasil dihapus',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'bab dipindahkan',
 
     // Books
     'book_create'                 => 'membuat buku',
-    'book_create_notification'    => 'Buku berhasil dibuat',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'update buku',
-    'book_update_notification'    => 'Buku berhasil diupdate',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'hapus buku',
-    'book_delete_notification'    => 'Buku berhasil dihapus',
-    'book_sort'                   => 'urutkan buku',
-    'book_sort_notification'      => 'Buku berhasil diurutkan',
+    'book_delete_notification'    => 'Book successfully deleted',
+    'book_sort'                   => 'buku yang diurutkan',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'membuat rak',
-    'bookshelf_create_notification'    => 'Rak berhasil dibuat',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'update rak',
-    'bookshelf_update_notification'    => 'Rak berhasil diupdate',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'hapus rak buku',
-    'bookshelf_delete_notification'    => 'Rak berhasil dihapus',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" telah ditambahkan ke favorit Anda',
+    'favourite_remove_notification' => '":name" telah dihapus dari favorit Anda',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Metode multi-faktor sukses dikonfigurasi',
+    'mfa_remove_method_notification' => 'Metode multi-faktor sukses dihapus',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'berkomentar pada',
-    'permissions_update'          => 'perbaharui izin',
+    'permissions_update'          => 'izin diperbarui',
 ];
index 0fc965dae3b2bab2770290b71813bf695f88005d..423c92ff6164b253ab8eaaab5d27b1c44bced4b4 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Email',
     'password' => 'Kata Sandi',
     'password_confirm' => 'Konfirmasi Kata Sandi',
-    'password_hint' => 'Harus lebih dari 7 karakter',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Lupa Password?',
     'remember_me' => 'Ingat saya',
     'ldap_email_hint' => 'Harap masukkan email yang akan digunakan untuk akun ini.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Domain email tersebut tidak memiliki akses ke aplikasi ini',
     'register_success' => 'Terima kasih telah mendaftar! Anda sekarang terdaftar dan masuk.',
 
-
     // Password Reset
     'reset_password' => 'Atur ulang kata sandi',
     'reset_password_send_instructions' => 'Masukkan email Anda di bawah ini dan Anda akan dikirimi email dengan tautan pengaturan ulang kata sandi.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Anda menerima email ini karena kami menerima permintaan pengaturan ulang kata sandi untuk akun Anda.',
     'email_reset_not_requested' => 'Jika Anda tidak meminta pengaturan ulang kata sandi, tidak ada tindakan lebih lanjut yang diperlukan.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Konfirmasikan email Anda di :appName',
     'email_confirm_greeting' => 'Terima kasih telah bergabung :appName!',
     'email_confirm_text' => 'Silakan konfirmasi alamat email Anda dengan mengklik tombol di bawah ini:',
     'email_confirm_action' => 'Konfirmasi email',
     'email_confirm_send_error' => 'Konfirmasi email diperlukan tetapi sistem tidak dapat mengirim email. Hubungi admin untuk memastikan email disiapkan dengan benar.',
-    'email_confirm_success' => 'Email Anda telah dikonfirmasi!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Email konfirmasi dikirim ulang, Harap periksa kotak masuk Anda.',
 
     'email_not_confirmed' => 'Alamat Email Tidak Dikonfirmasi',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Selamat datang di :appName!',
     'user_invite_page_text' => 'Untuk menyelesaikan akun Anda dan mendapatkan akses, Anda perlu mengatur kata sandi yang akan digunakan untuk masuk ke :appName pada kunjungan berikutnya.',
     'user_invite_page_confirm_button' => 'Konfirmasi Kata sandi',
-    'user_invite_success' => 'Atur kata sandi, Anda sekarang memiliki akses ke :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Apakah Anda yakin ingin menghapus metode autentikasi multi-faktor ini?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index c3cc8977dd72c9535c73371d68ba80327195117d..b6c1f8d75a257e2f4449690b117eb1322a5980c1 100644 (file)
@@ -5,85 +5,98 @@
 return [
 
     // Buttons
-    'cancel' => 'Batalkan',
+    'cancel' => 'Batal',
     'confirm' => 'Konfirmasi',
     'back' => 'Kembali',
     'save' => 'Simpan',
-    'continue' => 'Selanjutnya',
+    'continue' => 'Lanjutkan',
     'select' => 'Pilih',
-    'toggle_all' => 'Alihkan semua',
-    'more' => 'Lebih',
+    'toggle_all' => 'Alihkan Semua',
+    'more' => 'Lebih banyak',
 
     // Form Labels
     'name' => 'Nama',
     'description' => 'Deskripsi',
-    'role' => 'Wewenang',
-    'cover_image' => 'Sampul Gambar',
+    'role' => 'Peran',
+    'cover_image' => 'Sampul gambar',
     'cover_image_description' => 'Gambar ini harus berukuran kira-kira 440x250 piksel.',
-    
+
     // Actions
     'actions' => 'Tindakan',
-    'view' => 'Melihat',
+    'view' => 'Lihat',
     'view_all' => 'Lihat Semua',
     'create' => 'Buat',
-    'update' => 'Perbaharui',
+    'update' => 'Perbarui',
     'edit' => 'Sunting',
     'sort' => 'Sortir',
     'move' => 'Pindahkan',
     'copy' => 'Salin',
-    'reply' => 'Balasan',
+    'reply' => 'Balas',
     'delete' => 'Hapus',
     'delete_confirm' => 'Konfirmasi Penghapusan',
     'search' => 'Cari',
     'search_clear' => 'Hapus Pencarian',
-    'reset' => 'Setel Ulang',
+    'reset' => 'Atur ulang',
     'remove' => 'Hapus',
     'add' => 'Tambah',
+    'configure' => 'Configure',
     'fullscreen' => 'Layar Penuh',
+    'favourite' => 'Favorit',
+    'unfavourite' => 'Batal favorit',
+    'next' => 'Selanjutnya',
+    'previous' => 'Sebelumnya',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
-    'sort_options' => 'Sortir Pilihan',
-    'sort_direction_toggle' => 'Urutkan Arah Toggle',
-    'sort_ascending' => 'Sortir Naik',
-    'sort_descending' => 'Urutkan Menurun',
+    'sort_options' => 'Opsi Sortir',
+    'sort_direction_toggle' => 'Urutkan Arah Alihan',
+    'sort_ascending' => 'Sortir Menaik',
+    'sort_descending' => 'Sortir Menurun',
     'sort_name' => 'Nama',
     'sort_default' => 'Bawaan',
-    'sort_created_at' => 'Tanggal dibuat',
-    'sort_updated_at' => 'Tanggal diperbaharui',
+    'sort_created_at' => 'Tanggal Dibuat',
+    'sort_updated_at' => 'Tanggal Diperbarui',
 
     // Misc
-    'deleted_user' => 'Pengguna terhapus',
-    'no_activity' => 'Tidak ada aktifitas untuk ditampilkan',
+    'deleted_user' => 'Pengguna yang Dihapus',
+    'no_activity' => 'Tidak ada aktivitas untuk ditampilkan',
     'no_items' => 'Tidak ada item yang tersedia',
     'back_to_top' => 'Kembali ke atas',
-    'toggle_details' => 'Detail Toggle',
+    'skip_to_main_content' => 'Lewatkan ke konten utama',
+    'toggle_details' => 'Rincian Alihan',
     'toggle_thumbnails' => 'Alihkan Gambar Mini',
-    'details' => 'Detail',
-    'grid_view' => 'Tampilan bergaris',
-    'list_view' => 'Daftar Tampilan',
-    'default' => 'Default',
+    'details' => 'Rincian',
+    'grid_view' => 'Tampilan Bergaris',
+    'list_view' => 'Tampilan Daftar',
+    'default' => 'Bawaan',
     'breadcrumb' => 'Breadcrumb',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
-    'profile_menu' => 'Profile Menu',
-    'view_profile' => 'Tampilkan profil',
+    'header_menu_expand' => 'Perluas Menu Tajuk',
+    'profile_menu' => 'Menu Profil',
+    'view_profile' => 'Tampilkan Profil',
     'edit_profile' => 'Sunting Profil',
     'dark_mode' => 'Mode Gelap',
-    'light_mode' => 'Mode Cahaya',
+    'light_mode' => 'Mode Terang',
 
     // Layout tabs
     'tab_info' => 'Informasi',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Tab: Tampilkan Informasi Sekunder',
     'tab_content' => 'Konten',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Tab: Tampilkan Informasi Utama',
 
     // Email Content
-    'email_action_help' => 'Jika Anda mengalami masalah saat mengklik tombol ":actionText", salin dan tempel URL di bawah ini ke browser web Anda:',
-    'email_rights' => 'Seluruh hak cipta',
+    'email_action_help' => 'Jika Anda mengalami masalah saat mengklik tombol ":actionText", salin dan tempel URL di bawah ke dalam peramban web Anda:',
+    'email_rights' => 'Hak cipta dilindungi',
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Rahasia pribadi',
-    'terms_of_service' => 'Persyaratan Layanan',
+    'privacy_policy' => 'Kebijakan Privasi',
+    'terms_of_service' => 'Ketentuan Layanan',
 ];
index 7764c1a3eb0e0052c081b071a3404c738d701200..366776c86a136666722e6794e2dbedab9e172f7c 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Gambar-gambar',
     'my_recent_drafts' => 'Draf Terbaru Saya',
     'my_recently_viewed' => 'Baru saja saya lihat',
+    'my_most_viewed_favourites' => 'Favorit Saya yang Paling Banyak Dilihat',
+    'my_favourites' => 'Favoritku',
     'no_pages_viewed' => 'Anda belum melihat halaman apa pun',
     'no_pages_recently_created' => 'Tidak ada halaman yang baru saja dibuat',
     'no_pages_recently_updated' => 'Tidak ada halaman yang baru-baru ini diperbarui',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'File Web Berisi',
     'export_pdf' => 'Dokumen PDF',
     'export_text' => 'Dokumen Teks Biasa',
+    'export_md' => 'File Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Izin',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Izin Rak Buku',
     'shelves_permissions_updated' => 'Izin Rak Buku Diperbarui',
     'shelves_permissions_active' => 'Izin Rak Buku Aktif',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Salin Izin ke Buku',
     'shelves_copy_permissions' => 'Salin Izin',
     'shelves_copy_permissions_explain' => 'Ini akan menerapkan setelan izin rak buku ini saat ini ke semua buku yang ada di dalamnya. Sebelum mengaktifkan, pastikan setiap perubahan pada izin rak buku ini telah disimpan.',
@@ -118,7 +122,7 @@ return [
     'books_delete_explain' => 'Ini akan menghapus buku dengan nama \': bookName\'. Semua halaman dan bab akan dihapus.',
     'books_delete_confirmation' => 'Apakah Anda yakin ingin menghapus buku ini?',
     'books_edit' => 'Edit Buku',
-    'books_edit_named' => 'Edit Buku :bookName',
+    'books_edit_named' => 'Sunting Buku :bookName',
     'books_form_book_name' => 'Nama Buku',
     'books_save' => 'Simpan Buku',
     'books_permissions' => 'Izin Buku',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Bab Terakhir',
     'books_sort_show_other' => 'Tunjukkan Buku Lain',
     'books_sort_save' => 'Simpan Pesanan Baru',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Bab',
@@ -152,11 +158,13 @@ return [
     'chapters_delete_explain' => 'Ini akan menghapus chapter dengan nama \':chapterName\'. Semua halaman yang ada dalam bab ini juga akan dihapus.',
     'chapters_delete_confirm' => 'Anda yakin ingin menghapus bab ini?',
     'chapters_edit' => 'Edit Bab',
-    'chapters_edit_named' => 'Edit Bab :chapterName',
+    'chapters_edit_named' => 'Sunting Bab :chapterName',
     'chapters_save' => 'Simpan Bab',
     'chapters_move' => 'Pindahkan Bab',
     'chapters_move_named' => 'Pindahkan Bab :chapterName',
     'chapter_move_success' => 'Bab dipindahkan ke :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Izin Bab',
     'chapters_empty' => 'Saat ini tidak ada halaman dalam bab ini.',
     'chapters_permissions_active' => 'Izin Bab Aktif',
@@ -179,7 +187,7 @@ return [
     'pages_delete_draft_success' => 'Halaman draf dihapus',
     'pages_delete_confirm' => 'Anda yakin ingin menghapus halaman ini?',
     'pages_delete_draft_confirm' => 'Anda yakin ingin menghapus halaman draf ini?',
-    'pages_editing_named' => 'Mengedit Halaman :pageName',
+    'pages_editing_named' => 'Menyunting Halaman :pageName',
     'pages_edit_draft_options' => 'Opsi Draf',
     'pages_edit_save_draft' => 'Simpan Draf',
     'pages_edit_draft' => 'Edit Halaman Draf',
@@ -188,7 +196,7 @@ return [
     'pages_edit_draft_save_at' => 'Draf disimpan pada ',
     'pages_edit_delete_draft' => 'Hapus Draf',
     'pages_edit_discard_draft' => 'Buang Draf',
-    'pages_edit_set_changelog' => 'Setel Changelog',
+    'pages_edit_set_changelog' => 'Atur Changelog',
     'pages_edit_enter_changelog_desc' => 'Masukkan deskripsi singkat tentang perubahan yang Anda buat',
     'pages_edit_enter_changelog' => 'Masuk ke Changelog',
     'pages_save' => 'Simpan Halaman',
@@ -224,20 +232,21 @@ return [
     'pages_revisions_restore' => 'Mengembalikan',
     'pages_revisions_none' => 'Halaman ini tidak memiliki revisi',
     'pages_copy_link' => 'Salin tautan',
-    'pages_edit_content_link' => 'Edit Konten',
+    'pages_edit_content_link' => 'Sunting Konten',
     'pages_permissions_active' => 'Izin Halaman Aktif',
     'pages_initial_revision' => 'Penerbitan awal',
     'pages_initial_name' => 'Halaman Baru',
-    'pages_editing_draft_notification' => 'Anda sedang mengedit draf yang terakhir disimpan :timeDiff.',
+    'pages_editing_draft_notification' => 'Anda sedang menyunting konsep yang terakhir disimpan :timeDiff.',
     'pages_draft_edited_notification' => 'Halaman ini telah diperbarui sejak saat itu. Anda disarankan untuk membuang draf ini.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count pengguna sudah mulai mengedit halaman ini',
-        'start_b' => ':userName sudah mulai mengedit halaman ini',
-        'time_a' => 'perubahan di sini disimpan secara instan',
+        'start_b' => ':userName telah memulai menyunting halaman ini',
+        'time_a' => 'semenjak halaman terakhir diperbarui',
         'time_b' => 'di akhir :minCount menit',
         'message' => ':start :time. Berhati-hatilah untuk tidak menimpa pembaruan satu sama lain!',
     ],
-    'pages_draft_discarded' => 'Draf dibuang, Editor telah diperbarui dengan konten halaman saat ini',
+    'pages_draft_discarded' => 'Konsep dibuang, Penyunting telah diperbarui dengan konten halaman saat ini',
     'pages_specific' => 'Halaman Tertentu',
     'pages_is_template' => 'Template Halaman',
 
@@ -253,30 +262,40 @@ return [
     'tags_explain' => "Tambahkan beberapa tag untuk mengkategorikan konten Anda dengan lebih baik.\n Anda dapat menetapkan nilai ke tag untuk pengaturan yang lebih mendalam.",
     'tags_add' => 'Tambahkan tag lain',
     'tags_remove' => 'Hapus tag ini',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Lampiran',
-    'attachments_explain' => 'Unggah beberapa file atau lampirkan beberapa tautan untuk ditampilkan di laman Anda. Ini terlihat di sidebar halaman.',
+    'attachments_explain' => 'Unggah beberapa berkas atau lampirkan beberapa tautan untuk ditampilkan di laman Anda. Ini terlihat di sidebar halaman.',
     'attachments_explain_instant_save' => 'Perubahan di sini disimpan secara instan.',
     'attachments_items' => 'Item Terlampir',
-    'attachments_upload' => 'Unggah File',
+    'attachments_upload' => 'Unggah Berkas',
     'attachments_link' => 'Lampirkan Tautan',
     'attachments_set_link' => 'Setel Tautan',
     'attachments_delete' => 'Anda yakin ingin menghapus lampiran ini?',
     'attachments_dropzone' => 'Jatuhkan file atau klik di sini untuk melampirkan file',
-    'attachments_no_files' => 'Tidak ada file yang telah diunggah',
-    'attachments_explain_link' => 'Anda dapat melampirkan link jika Anda memilih untuk tidak mengupload file. Ini bisa berupa tautan ke halaman lain atau tautan ke file di cloud.',
+    'attachments_no_files' => 'Tidak ada berkas yang telah diunggah',
+    'attachments_explain_link' => 'Anda dapat melampirkan sebuah tautan jika Anda memilih untuk tidak mengunggah berkas. Ini bisa berupa sebuah tautan ke halaman lain atau tautan ke sebuah berkas di cloud.',
     'attachments_link_name' => 'Nama Tautan',
     'attachment_link' => 'Lampiran Tautan',
     'attachments_link_url' => 'Tautan ke file',
-    'attachments_link_url_hint' => 'Url situs atau file',
+    'attachments_link_url_hint' => 'Alamat url situs atau berkas',
     'attach' => 'Melampirkan',
     'attachments_insert_link' => 'Tambahkan Tautan Lampiran ke Halaman',
     'attachments_edit_file' => 'Edit File',
     'attachments_edit_file_name' => 'Nama file',
-    'attachments_edit_drop_upload' => 'Lepaskan file atau klik di sini untuk mengupload dan menimpa',
+    'attachments_edit_drop_upload' => 'Jatuhkan berkas atau klik di sini untuk mengunggah dan menimpa',
     'attachments_order_updated' => 'Urutan lampiran diperbarui',
     'attachments_updated_success' => 'Detail lampiran diperbarui',
     'attachments_deleted' => 'Lampiran dihapus',
-    'attachments_file_uploaded' => 'File berhasil diunggah',
+    'attachments_file_uploaded' => 'Berkas berhasil diunggah',
     'attachments_file_updated' => 'File berhasil diperbarui',
     'attachments_link_attached' => 'Tautan berhasil dilampirkan ke halaman',
     'templates' => 'Template',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Anda yakin ingin menghapus revisi ini?',
     'revision_restore_confirm' => 'Apakah Anda yakin ingin memulihkan revisi ini? Konten halaman saat ini akan diganti.',
     'revision_delete_success' => 'Revisi dihapus',
-    'revision_cannot_delete_latest' => 'Tidak dapat menghapus revisi terakhir.'
+    'revision_cannot_delete_latest' => 'Tidak dapat menghapus revisi terakhir.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index ec3564ece1c572099e672dabb66e3c1b6a08ac41..4f71eeb00458d943f25e628a21f913d0aedbf6ba 100644 (file)
@@ -15,42 +15,46 @@ return [
     'email_confirmation_expired' => 'Token konfirmasi telah kedaluwarsa, Email konfirmasi baru telah dikirim.',
     'email_confirmation_awaiting' => 'Alamat email untuk akun yang digunakan perlu dikonfirmasi',
     'ldap_fail_anonymous' => 'Akses LDAP gagal menggunakan pengikatan anonim',
-    'ldap_fail_authed' => 'Akses LDAP gagal menggunakan detail dn & sandi yang diberikan',
+    'ldap_fail_authed' => 'Akses LDAP gagal menggunakan rincian dn & sandi yang diberikan',
     'ldap_extension_not_installed' => 'Ekstensi LDAP PHP tidak terpasang',
     'ldap_cannot_connect' => 'Tidak dapat terhubung ke server ldap, Koneksi awal gagal',
     'saml_already_logged_in' => 'Telah masuk',
     'saml_user_not_registered' => 'Pengguna :name tidak terdaftar dan pendaftaran otomatis dinonaktifkan',
-    'saml_no_email_address' => 'Tidak dapat menemukan alamat email untuk pengguna ini dalam data yang diberikan oleh sistem autentikasi eksternal',
-    'saml_invalid_response_id' => 'Permintaan dari sistem otentikasi eksternal tidak dikenali oleh proses yang dimulai oleh aplikasi ini. Menavigasi kembali setelah masuk dapat menyebabkan masalah ini.',
-    'saml_fail_authed' => 'Login menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil',
+    'saml_no_email_address' => 'Tidak dapat menemukan sebuah alamat email untuk pengguna ini, dalam data yang diberikan oleh sistem autentikasi eksternal',
+    'saml_invalid_response_id' => 'Permintaan dari sistem otentikasi eksternal tidak dikenali oleh sebuah proses yang dimulai oleh aplikasi ini. Menavigasi kembali setelah masuk dapat menyebabkan masalah ini.',
+    'saml_fail_authed' => 'Masuk menggunakan :system gagal, sistem tidak memberikan otorisasi yang berhasil',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Tidak ada tindakan yang ditentukan',
-    'social_login_bad_response' => "Kesalahan diterima selama :socialAccount :\n:error",
-    'social_account_in_use' => 'Ini:socialAccount sudah digunakan, Coba masuk melalui opsi :socialAccount.',
+    'social_login_bad_response' => "Kesalahan yang diterima selama masuk menggunakan :socialAccount : \n:error",
+    'social_account_in_use' => 'Akun :socialAccount ini sudah digunakan, Coba masuk melalui opsi :socialAccount.',
     'social_account_email_in_use' => 'Email :email sudah digunakan. Jika Anda sudah memiliki akun, Anda dapat menghubungkan :socialAccount Anda dari pengaturan profil Anda.',
-    'social_account_existing' => 'Akun ini :socialAccount sudah dilampirkan ke profil Anda.',
-    'social_account_already_used_existing' => 'Akun ini :socialAccount sudah digunakan oleh pengguna lain.',
-    'social_account_not_used' => 'Akun :socialAccount tidak ditautkan ke pengguna mana pun. Harap lampirkan di pengaturan profil Anda. ',
+    'social_account_existing' => 'Akun :socialAccount ini sudah dilampirkan ke profil Anda.',
+    'social_account_already_used_existing' => 'Akun :socialAccount ini sudah digunakan oleh pengguna lain.',
+    'social_account_not_used' => 'Akun :socialAccount ini tidak ditautkan ke pengguna mana pun. Harap lampirkan di dalam pengaturan profil Anda. ',
     'social_account_register_instructions' => 'Jika Anda belum memiliki akun, Anda dapat mendaftarkan akun menggunakan opsi :socialAccount.',
     'social_driver_not_found' => 'Pengemudi sosial tidak ditemukan',
     'social_driver_not_configured' => 'Pengaturan sosial :socialAccount Anda tidak dikonfigurasi dengan benar.',
     'invite_token_expired' => 'Tautan undangan ini telah kedaluwarsa. Sebagai gantinya, Anda dapat mencoba mengatur ulang kata sandi akun Anda.',
 
     // System
-    'path_not_writable' => 'Jalur file :filePath tidak dapat diunggah ke. Pastikan itu dapat ditulis ke server.',
-    'cannot_get_image_from_url' => 'Tidak bisa mendapatkan gambar dari :url',
+    'path_not_writable' => 'Jalur berkas :filePath tidak dapat diunggah. Pastikan berkas tersebut dapat ditulis ke server.',
+    'cannot_get_image_from_url' => 'Tidak dapat mengambil gambar dari :url',
     'cannot_create_thumbs' => 'Server tidak dapat membuat thumbnail. Harap periksa apakah Anda telah memasang ekstensi GD PHP.',
-    'server_upload_limit' => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran file yang lebih kecil.',
-    'uploaded'  => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran file yang lebih kecil.',
-    'image_upload_error' => 'Terjadi kesalahan saat mengupload gambar',
+    'server_upload_limit' => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran berkas yang lebih kecil.',
+    'uploaded'  => 'Server tidak mengizinkan unggahan dengan ukuran ini. Harap coba ukuran berkas yang lebih kecil.',
+    'image_upload_error' => 'Terjadi kesalahan saat mengunggah gambar',
     'image_upload_type_error' => 'Jenis gambar yang diunggah tidak valid',
-    'file_upload_timeout' => 'Waktu unggah file telah habis.',
+    'file_upload_timeout' => 'Unggahan berkas telah habis waktu.',
 
     // Attachments
     'attachment_not_found' => 'Lampiran tidak ditemukan',
 
     // Pages
     'page_draft_autosave_fail' => 'Gagal menyimpan draf. Pastikan Anda memiliki koneksi internet sebelum menyimpan halaman ini',
-    'page_custom_home_deletion' => 'Tidak dapat menghapus halaman saat disetel sebagai beranda',
+    'page_custom_home_deletion' => 'Tidak dapat menghapus sebuah halaman saat diatur sebagai sebuah halaman beranda',
 
     // Entities
     'entity_not_found' => 'Entitas tidak ditemukan',
@@ -67,7 +71,7 @@ return [
     'users_cannot_delete_guest' => 'Anda tidak dapat menghapus pengguna tamu',
 
     // Roles
-    'role_cannot_be_edited' => 'Peran ini tidak dapat diedit',
+    'role_cannot_be_edited' => 'Peran ini tidak dapat disunting',
     'role_system_cannot_be_deleted' => 'Peran ini adalah peran sistem dan tidak dapat dihapus',
     'role_registration_default_cannot_delete' => 'Peran ini tidak dapat dihapus jika disetel sebagai peran pendaftaran default',
     'role_cannot_remove_only_admin' => 'Pengguna ini adalah satu-satunya pengguna yang ditetapkan ke peran administrator. Tetapkan peran administrator untuk pengguna lain sebelum mencoba untuk menghapusnya di sini.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Halaman tidak ditemukan',
     'sorry_page_not_found' => 'Maaf, Halaman yang Anda cari tidak dapat ditemukan.',
     'sorry_page_not_found_permission_warning' => 'Jika Anda mengharapkan halaman ini ada, Anda mungkin tidak memiliki izin untuk melihatnya.',
+    'image_not_found' => 'Gambar tidak ditemukan',
+    'image_not_found_subtitle' => 'Maaf, Berkas gambar yang Anda cari tidak dapat ditemukan.',
+    'image_not_found_details' => 'Jika Anda mengharapkan gambar ini ada, gambar itu mungkin telah dihapus.',
     'return_home' => 'Kembali ke home',
     'error_occurred' => 'Terjadi kesalahan',
     'app_down' => ':appName sedang down sekarang',
index 7f83fbad885155c1c278dc66496f348ae7586a7e..df5ccac6437ecab4f81d657b66a74b267a968fcb 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Pemeliharaan',
     'maint_image_cleanup' => 'Gambar Bersihkan',
-    'maint_image_cleanup_desc' => "Pindai halaman & konten revisi untuk memeriksa gambar dan gambar mana yang saat ini digunakan dan gambar mana yang berlebihan. Pastikan Anda membuat database lengkap dan cadangan gambar sebelum menjalankan ini.",
+    'maint_image_cleanup_desc' => 'Pindai halaman & konten revisi untuk memeriksa gambar dan gambar mana yang saat ini digunakan dan gambar mana yang berlebihan. Pastikan Anda membuat database lengkap dan cadangan gambar sebelum menjalankan ini.',
     'maint_delete_images_only_in_revisions' => 'Hapus juga gambar yang hanya ada di revisi halaman lama',
     'maint_image_cleanup_run' => 'Jalankan Pembersihan',
     'maint_image_cleanup_warning' => ':count ditemukan gambar yang berpotensi tidak digunakan. Anda yakin ingin menghapus gambar-gambar ini?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Tempat Sampah',
     'recycle_bin_desc' => 'Di sini Anda dapat memulihkan item yang telah dihapus atau memilih untuk menghapusnya secara permanen dari sistem. Daftar ini tidak difilter, tidak seperti daftar aktivitas serupa di sistem tempat filter izin diterapkan.',
     'recycle_bin_deleted_item' => 'Item yang Dihapus',
+    'recycle_bin_deleted_parent' => 'Induk',
     'recycle_bin_deleted_by' => 'Dihapus Oleh',
     'recycle_bin_deleted_at' => 'Waktu Penghapusan',
     'recycle_bin_permanently_delete' => 'Hapus Permanen',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Item yang akan Dipulihkan',
     'recycle_bin_restore_confirm' => 'Tindakan ini akan memulihkan item yang dihapus, termasuk semua elemen anak, ke lokasi aslinya. Jika lokasi asli telah dihapus, dan sekarang berada di keranjang sampah, item induk juga perlu dipulihkan.',
     'recycle_bin_restore_deleted_parent' => 'Induk item ini juga telah dihapus. Ini akan tetap dihapus sampai induknya juga dipulihkan.',
+    'recycle_bin_restore_parent' => 'Pulihkan Induk',
     'recycle_bin_destroy_notification' => 'Total :count item dari tempat sampah.',
     'recycle_bin_restore_notification' => 'Total :count item yang dipulihkan dari tempat sampah.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Pengguna',
     'audit_table_event' => 'Peristiwa',
     'audit_table_related' => 'Item atau Detail Terkait',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Tanggal Kegiatan',
     'audit_date_from' => 'Rentang Tanggal Dari',
     'audit_date_to' => 'Rentang Tanggal Sampai',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Detail Peran',
     'role_name' => 'Nama peran',
     'role_desc' => 'Deskripsi Singkat Peran',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Otentikasi Eksternal IDs',
     'role_system' => 'Izin Sistem',
     'role_manage_users' => 'Kelola pengguna',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Kelola template halaman',
     'role_access_api' => 'Akses Sistem API',
     'role_manage_settings' => 'Kelola setelan aplikasi',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Peran Pengguna',
     'users_role_desc' => 'Pilih peran mana yang akan ditetapkan untuk pengguna ini. Jika pengguna ditetapkan ke beberapa peran, izin dari peran tersebut akan bertumpuk dan mereka akan menerima semua kemampuan dari peran yang ditetapkan.',
     'users_password' => 'Kata Sandi Pengguna',
-    'users_password_desc' => 'Atur kata sandi yang digunakan untuk masuk ke aplikasi. Panjangnya minimal harus 6 karakter.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Anda dapat memilih untuk mengirimi pengguna ini email undangan yang memungkinkan mereka menyetel sandi mereka sendiri, atau Anda dapat menyetel sandi mereka sendiri.',
     'users_send_invite_option' => 'Kirim email undangan pengguna',
     'users_external_auth_id' => 'Otentikasi Eksternal ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Buat Token',
     'users_api_tokens_expires' => 'Kedaluwarsa',
     'users_api_tokens_docs' => 'Dokumentasi API',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Buat Token API',
@@ -214,7 +223,7 @@ return [
     'user_api_token_update_success' => 'Token API berhasil diperbarui',
     'user_api_token' => 'Token API',
     'user_api_token_id' => 'Token ID',
-    'user_api_token_id_desc' => 'Ini adalah pengenal yang dibuat oleh sistem yang tidak dapat diedit untuk token ini yang perlu disediakan dalam permintaan API.',
+    'user_api_token_id_desc' => 'Ini adalah sebuah pengenal yang dihasilkan oleh sistem yang tidak dapat disunting untuk token ini yang perlu untuk disediakan dalam permintaan API.',
     'user_api_token_secret' => 'Token Secret',
     'user_api_token_secret_desc' => 'Ini adalah rahasia yang dihasilkan sistem untuk token ini yang perlu disediakan dalam permintaan API. Ini hanya akan ditampilkan kali ini jadi salin nilai ini ke tempat yang aman dan terlindungi.',
     'user_api_token_created' => 'Token dibuat :timeAgo',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Anda yakin ingin menghapus token API ini?',
     'user_api_token_delete_success' => 'Token API berhasil dihapus',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 10ac2900ece859b63ee09c08ac36cbca3715ebcd..992f403299a3ea53884071022e8e6bf8cc6f43bd 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute hanya boleh berisi huruf, angka, tanda hubung, dan garis bawah.',
     'alpha_num'            => ':attribute hanya boleh berisi huruf dan angka.',
     'array'                => ':attribute harus berupa larik.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute harus tanggal sebelum :date.',
     'between'              => [
         'numeric' => ':attribute harus di antara :min dan :max.',
@@ -98,9 +99,10 @@ return [
     ],
     'string'               => ':attribute harus berupa string.',
     'timezone'             => ':attribute harus menjadi zona yang valid.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute sudah diambil.',
     'url'                  => ':attribute format tidak valid.',
-    'uploaded'             => 'File tidak dapat diunggah. Server mungkin tidak menerima file dengan ukuran ini.',
+    'uploaded'             => 'Berkas tidak dapat diunggah. Server mungkin tidak menerima berkas dengan ukuran ini.',
 
     // Custom validation lines
     'custom' => [
index adf2888fc2e6bc4d13e9f07bc4e897befbbfaa62..5696662f57d9040dca1d47b1b8a5b5bd884dea9b 100755 (executable)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'pagina creata',
-    'page_create_notification'    => 'Pagina Creata Correttamente',
+    'page_create_notification'    => 'Pagina creata con successo',
     'page_update'                 => 'ha aggiornato la pagina',
-    'page_update_notification'    => 'Pagina Aggiornata Correttamente',
+    'page_update_notification'    => 'Pagina aggiornata con successo',
     'page_delete'                 => 'ha eliminato la pagina',
-    'page_delete_notification'    => 'Pagina Eliminata Correttamente',
+    'page_delete_notification'    => 'Pagina eliminata con successo',
     'page_restore'                => 'ha ripristinato la pagina',
-    'page_restore_notification'   => 'Pagina Ripristinata Correttamente',
+    'page_restore_notification'   => 'Pagina ripristinata con successo',
     'page_move'                   => 'ha mosso la pagina',
 
     // Chapters
     'chapter_create'              => 'ha creato il capitolo',
-    'chapter_create_notification' => 'Capitolo Creato Correttamente',
+    'chapter_create_notification' => 'Capitolo creato con successo',
     'chapter_update'              => 'ha aggiornato il capitolo',
-    'chapter_update_notification' => 'Capitolo Aggiornato Correttamente',
+    'chapter_update_notification' => 'Capitolo aggiornato con successo',
     'chapter_delete'              => 'ha eliminato il capitolo',
-    'chapter_delete_notification' => 'Capitolo Eliminato Correttamente',
+    'chapter_delete_notification' => 'Capitolo eliminato con successo',
     'chapter_move'                => 'ha spostato il capitolo',
 
     // Books
     'book_create'                 => 'ha creato il libro',
-    'book_create_notification'    => 'Libro Creato Correttamente',
+    'book_create_notification'    => 'Libro creato con successo',
     'book_update'                 => 'ha aggiornato il libro',
-    'book_update_notification'    => 'Libro Aggiornato Correttamente',
+    'book_update_notification'    => 'Libro aggiornato con successo',
     'book_delete'                 => 'ha eliminato il libro',
-    'book_delete_notification'    => 'Libro Eliminato Correttamente',
+    'book_delete_notification'    => 'Libro eliminato con successo',
     'book_sort'                   => 'ha ordinato il libro',
-    'book_sort_notification'      => 'Libro Riordinato Correttamente',
+    'book_sort_notification'      => 'Libro reindicizzato con successo',
 
     // Bookshelves
-    'bookshelf_create'            => 'ha creato la Libreria',
-    'bookshelf_create_notification'    => 'Libreria Creata Correttamente',
+    'bookshelf_create'            => 'libreria creata',
+    'bookshelf_create_notification'    => 'Libreria creata con successo',
     'bookshelf_update'                 => 'ha aggiornato la libreria',
-    'bookshelf_update_notification'    => 'Libreria Aggiornata Correttamente',
+    'bookshelf_update_notification'    => 'Libreria aggiornata con successo',
     'bookshelf_delete'                 => 'ha eliminato la libreria',
-    'bookshelf_delete_notification'    => 'Libreria Eliminata Correttamente',
+    'bookshelf_delete_notification'    => 'Libreria cancellata con successo',
+
+    // Favourites
+    'favourite_add_notification' => '":name" è stato aggiunto ai tuoi preferiti',
+    'favourite_remove_notification' => '":name" è stato rimosso dai tuoi preferiti',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Metodo multi-fattore impostato con successo',
+    'mfa_remove_method_notification' => 'Metodo multi-fattore rimosso con successo',
+
+    // Webhooks
+    'webhook_create' => 'webhook creato',
+    'webhook_create_notification' => 'Webhook creato con successo',
+    'webhook_update' => 'webhook aggiornato',
+    'webhook_update_notification' => 'Webhook aggiornato con successo',
+    'webhook_delete' => 'webhook eliminato',
+    'webhook_delete_notification' => 'Webhook eliminato con successo',
 
     // Other
     'commented_on'                => 'ha commentato in',
index a1c4b704831450ac3f61ce34cf13674e1c5b723f..3940b1ba197d8193d808e7de207d85f9bebf45e3 100755 (executable)
@@ -21,7 +21,7 @@ return [
     'email' => 'Email',
     'password' => 'Password',
     'password_confirm' => 'Conferma Password',
-    'password_hint' => 'Deve essere più di 7 caratteri',
+    'password_hint' => 'Deve essere lunga almeno 8 caratteri',
     'forgot_password' => 'Password dimenticata?',
     'remember_me' => 'Ricordami',
     'ldap_email_hint' => 'Inserisci un email per usare quest\'account.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Questo dominio della mail non ha accesso a questa applicazione',
     'register_success' => 'Grazie per la registrazione! Sei registrato e loggato.',
 
-
     // Password Reset
     'reset_password' => 'Reimposta Password',
     'reset_password_send_instructions' => 'Inserisci il tuo indirizzo sotto e ti verrà inviata una mail contenente un link per resettare la tua password.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Stai ricevendo questa mail perché abbiamo ricevuto una richiesta di reset della password per il tuo account.',
     'email_reset_not_requested' => 'Se non hai richiesto un reset della password, ignora questa mail.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Conferma email per :appName',
     'email_confirm_greeting' => 'Grazie per esserti registrato a :appName!',
     'email_confirm_text' => 'Conferma il tuo indirizzo email cliccando il pulsante sotto:',
     'email_confirm_action' => 'Conferma Email',
     'email_confirm_send_error' => 'La conferma della mail è richiesta ma non è stato possibile mandare la mail. Contatta l\'amministratore.',
-    'email_confirm_success' => 'La tua mail è stata confermata!',
+    'email_confirm_success' => 'La tua email è stata confermata! Ora dovresti essere in grado di effettuare il login utilizzando questo indirizzo email.',
     'email_confirm_resent' => 'Mail di conferma reinviata, controlla la tua posta.',
 
     'email_not_confirmed' => 'Indirizzo Email Non Confermato',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Benvenuto in :appName!',
     'user_invite_page_text' => 'Per completare il tuo account e ottenere l\'accesso devi impostare una password che verrà utilizzata per accedere a :appName in futuro.',
     'user_invite_page_confirm_button' => 'Conferma Password',
-    'user_invite_success' => 'Password impostata, ora hai accesso a :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password impostata, ora dovresti essere in grado di effettuare il login utilizzando la password impostata per accedere a :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Imposta Autenticazione Multi-Fattore',
+    'mfa_setup_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.',
+    'mfa_setup_configured' => 'Già configurata',
+    'mfa_setup_reconfigure' => 'Riconfigura',
+    'mfa_setup_remove_confirmation' => 'Sei sicuro di voler rimuovere questo metodo di autenticazione multi-fattore?',
+    'mfa_setup_action' => 'Imposta',
+    'mfa_backup_codes_usage_limit_warning' => 'Hai meno di 5 codici di backup rimanenti. Genera e memorizza un nuovo set prima di esaurire i codici per evitare di essere bloccato dal tuo account.',
+    'mfa_option_totp_title' => 'App mobile',
+    'mfa_option_totp_desc' => 'Per utilizzare l\'autenticazione multi-fattore avrai bisogno di un\'applicazione mobile che supporti TOTP come Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Codici di backup',
+    'mfa_option_backup_codes_desc' => 'Salva in modo sicuro una serie di codici di backup monouso che puoi inserire per verificare la tua identità.',
+    'mfa_gen_confirm_and_enable' => 'Conferma e abilita',
+    'mfa_gen_backup_codes_title' => 'Configurazione codici di backup',
+    'mfa_gen_backup_codes_desc' => 'Conserva l\'elenco di codici qui sotto in un luogo sicuro. Quando accedi al sistema potrai utilizzare uno dei codici come meccanismo di autenticazione secondario.',
+    'mfa_gen_backup_codes_download' => 'Scarica codici',
+    'mfa_gen_backup_codes_usage_warning' => 'Ogni codice può essere utilizzato solo una volta',
+    'mfa_gen_totp_title' => 'Impostazione App Mobile',
+    'mfa_gen_totp_desc' => 'Per utilizzare l\'autenticazione multi-fattore avrai bisogno di un\'applicazione mobile che supporti TOTP come Google Authenticator, Authy o Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scansiona il codice QR qui sotto utilizzando la tua app di autenticazione preferita per iniziare.',
+    'mfa_gen_totp_verify_setup' => 'Verifica configurazione',
+    'mfa_gen_totp_verify_setup_desc' => 'Verifica che tutto funzioni inserendo un codice, generato all\'interno della tua app di autenticazione, nella casella di testo sottostante:',
+    'mfa_gen_totp_provide_code_here' => 'Inserisci qui il codice generato dall\'app',
+    'mfa_verify_access' => 'Verifica accesso',
+    'mfa_verify_access_desc' => 'Il tuo account utente richiede che tu confermi la tua identità tramite un ulteriore livello di verifica prima di ottenere l\'accesso. Verifica usando uno dei tuoi metodi configurati per continuare.',
+    'mfa_verify_no_methods' => 'Nessun metodo configurato',
+    'mfa_verify_no_methods_desc' => 'Non è stato possibile trovare metodi di autenticazione multi-fattore per il tuo account. Devi impostare almeno un metodo prima di ottenere l\'accesso.',
+    'mfa_verify_use_totp' => 'Verifica utilizzando un\'app mobile',
+    'mfa_verify_use_backup_codes' => 'Verifica utilizzando un codice di backup',
+    'mfa_verify_backup_code' => 'Codice di backup',
+    'mfa_verify_backup_code_desc' => 'Inserisci uno dei tuoi rimanenti codici di backup qui sotto:',
+    'mfa_verify_backup_code_enter_here' => 'Inserisci qui il codice di backup',
+    'mfa_verify_totp_desc' => 'Inserisci il codice, generato tramite la tua app mobile, qui sotto:',
+    'mfa_setup_login_notification' => 'Metodo multi-fattore configurato, si prega di effettuare nuovamente il login utilizzando il metodo configurato.',
+];
index f24df1db90d9b335a12a8f24b3e23b5504b738c8..af57ef44eb220d1d4cb141c29ca15b1538a0e9ee 100755 (executable)
@@ -20,7 +20,7 @@ return [
     'role' => 'Ruolo',
     'cover_image' => 'Immagine di copertina',
     'cover_image_description' => 'Questa immagine dovrebbe essere approssimativamente 440x250px.',
-    
+
     // Actions
     'actions' => 'Azioni',
     'view' => 'Visualizza',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Azzera',
     'remove' => 'Rimuovi',
     'add' => 'Aggiungi',
+    'configure' => 'Configura',
     'fullscreen' => 'Schermo intero',
+    'favourite' => 'Aggiungi ai Preferiti',
+    'unfavourite' => 'Rimuovi dai preferiti',
+    'next' => 'Successivo',
+    'previous' => 'Precedente',
+    'filter_active' => 'Filtro attivo:',
+    'filter_clear' => 'Pulisci filtro',
 
     // Sort Options
     'sort_options' => 'Opzioni Ordinamento',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Nessuna attività da mostrare',
     'no_items' => 'Nessun elemento disponibile',
     'back_to_top' => 'Torna in alto',
+    'skip_to_main_content' => 'Passa al contenuto principale',
     'toggle_details' => 'Mostra Dettagli',
     'toggle_thumbnails' => 'Mostra Miniature',
     'details' => 'Dettagli',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Visualizzazione Lista',
     'default' => 'Predefinito',
     'breadcrumb' => 'Navigazione',
+    'status' => 'Stato',
+    'status_active' => 'Attivo',
+    'status_inactive' => 'Inattivo',
+    'never' => 'Mai',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Espandi Menù Intestazione',
index aa1c8f4a68fea41b80bb5b590ec11ba72944b226..63637aa484fccf69450ce473aad73e8584767663 100755 (executable)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Carica Altre',
     'image_image_name' => 'Nome Immagine',
     'image_delete_used' => 'Questa immagine è usata nelle pagine elencate.',
-    'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
+    'image_delete_confirm_text' => 'Sei sicuro di voler eliminare questa immagine?',
     'image_select_image' => 'Seleziona Immagine',
     'image_dropzone' => 'Rilascia immagini o clicca qui per caricarle',
     'images_deleted' => 'Immagini Eliminate',
index a567936daeebfdb4ab61980381cb04a109196ad2..9c9c0d58ddf235f57e9f954a951cb4d028ae1b2a 100755 (executable)
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => 'Creato :timeLength da :user',
     'meta_updated' => 'Aggiornato :timeLength',
     'meta_updated_name' => 'Aggiornato :timeLength da :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Creati da :user',
     'entity_select' => 'Selezione Entità',
     'images' => 'Immagini',
     'my_recent_drafts' => 'Bozze Recenti',
     'my_recently_viewed' => 'Visti di recente',
+    'my_most_viewed_favourites' => 'I Miei Preferiti Più Visti',
+    'my_favourites' => 'I miei Preferiti',
     'no_pages_viewed' => 'Non hai visto nessuna pagina',
     'no_pages_recently_created' => 'Nessuna pagina è stata creata di recente',
     'no_pages_recently_updated' => 'Nessuna pagina è stata aggiornata di recente',
@@ -34,13 +36,14 @@ return [
     'export_html' => 'File Contenuto Web',
     'export_pdf' => 'File PDF',
     'export_text' => 'File di testo',
+    'export_md' => 'File Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Permessi',
     'permissions_intro' => 'Una volta abilitati, questi permessi avranno la priorità su tutti gli altri.',
     'permissions_enable' => 'Abilita Permessi Custom',
     'permissions_save' => 'Salva Permessi',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Proprietario',
 
     // Search
     'search_results' => 'Risultati Ricerca',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Permessi impostati',
     'search_created_by_me' => 'Creati da me',
     'search_updated_by_me' => 'Aggiornati da me',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Creati da me',
     'search_date_options' => 'Opzioni Data',
     'search_updated_before' => 'Aggiornati prima del',
     'search_updated_after' => 'Aggiornati dopo il',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permessi Libreria',
     'shelves_permissions_updated' => 'Permessi Libreria Aggiornati',
     'shelves_permissions_active' => 'Permessi Attivi Libreria',
+    'shelves_permissions_cascade_warning' => 'I permessi sugli scaffali non si estendono automaticamente ai libri contenuti. Questo avviene in quanto un libro può essere presente su più scaffali. I permessi possono comunque essere copiati ai libri contenuti usando l\'opzione qui sotto.',
     'shelves_copy_permissions_to_books' => 'Copia Permessi ai Libri',
     'shelves_copy_permissions' => 'Copia Permessi',
     'shelves_copy_permissions_explain' => 'Verranno applicati tutti i permessi della libreria ai libri contenuti. Prima di attivarlo, assicurati che ogni permesso di questa libreria sia salvato.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Capitoli Per Ultimi',
     'books_sort_show_other' => 'Mostra Altri Libri',
     'books_sort_save' => 'Salva il nuovo ordine',
+    'books_copy' => 'Copia Libro',
+    'books_copy_success' => 'Libro copiato con successo',
 
     // Chapters
     'chapter' => 'Capitolo',
@@ -149,7 +155,7 @@ return [
     'chapters_create' => 'Crea un nuovo capitolo',
     'chapters_delete' => 'Elimina Capitolo',
     'chapters_delete_named' => 'Elimina il capitolo :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
+    'chapters_delete_explain' => 'Procedendo si eliminerà il capitolo denominato \':chapterName\'. Anche le pagine in esso contenute saranno eliminate.',
     'chapters_delete_confirm' => 'Sei sicuro di voler eliminare questo capitolo?',
     'chapters_edit' => 'Elimina Capitolo',
     'chapters_edit_named' => 'Modifica il capitolo :chapterName',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Muovi Capitolo',
     'chapters_move_named' => 'Muovi il capitolo :chapterName',
     'chapter_move_success' => 'Capitolo mosso in :bookName',
+    'chapters_copy' => 'Copia Capitolo',
+    'chapters_copy_success' => 'Capitolo copiato con successo',
     'chapters_permissions' => 'Permessi Capitolo',
     'chapters_empty' => 'Non ci sono pagine in questo capitolo.',
     'chapters_permissions_active' => 'Permessi Capitolo Attivi',
@@ -211,7 +219,7 @@ return [
     'pages_revisions' => 'Versioni Pagina',
     'pages_revisions_named' => 'Versioni della pagina :pageName',
     'pages_revision_named' => 'Versione della pagina :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Ripristinato da #:id; :summary',
     'pages_revisions_created_by' => 'Creata Da',
     'pages_revisions_date' => 'Data Versione',
     'pages_revisions_number' => '#',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nuova Pagina',
     'pages_editing_draft_notification' => 'Stai modificando una bozza che è stata salvata il :timeDiff.',
     'pages_draft_edited_notification' => 'Questa pagina è stata aggiornata. È consigliabile scartare questa bozza.',
+    'pages_draft_page_changed_since_creation' => 'Questa pagina è stata aggiornata da quando è stata creata questa bozza. Si consiglia di scartare questa bozza o fare attenzione a non sovrascrivere alcun cambiamento di pagina.',
     'pages_draft_edit_active' => [
         'start_a' => ':count hanno iniziato a modificare questa pagina',
         'start_b' => ':userName ha iniziato a modificare questa pagina',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Aggiungi tag per categorizzare meglio il contenuto. \n Puoi assegnare un valore ai tag per una migliore organizzazione.",
     'tags_add' => 'Aggiungi un altro tag',
     'tags_remove' => 'Rimuovi questo tag',
+    'tags_usages' => 'Utilizzo totale dei tag',
+    'tags_assigned_pages' => 'Assegnato alle Pagine',
+    'tags_assigned_chapters' => 'Assegnato ai capitoli',
+    'tags_assigned_books' => 'Assegnato a Libri',
+    'tags_assigned_shelves' => 'Assegnato alle Librerie',
+    'tags_x_unique_values' => ':count valori univoci',
+    'tags_all_values' => 'Tutti i valori',
+    'tags_view_tags' => 'Visualizza tag',
+    'tags_view_existing_tags' => 'Usa i tag esistenti',
+    'tags_list_empty_hint' => 'I tag possono essere assegnati tramite la barra laterale dell\'editor di pagina o durante la modifica dei dettagli di un libro, capitolo o libreria.',
     'attachments' => 'Allegati',
     'attachments_explain' => 'Carica alcuni file o allega link per visualizzarli nella pagina. Questi sono visibili nella sidebar della pagina.',
     'attachments_explain_instant_save' => 'I cambiamenti qui sono salvati istantaneamente.',
@@ -269,7 +288,7 @@ return [
     'attachments_link_url' => 'Link al file',
     'attachments_link_url_hint' => 'Url del sito o del file',
     'attach' => 'Allega',
-    'attachments_insert_link' => 'Add Attachment Link to Page',
+    'attachments_insert_link' => 'Aggiungi Link Allegato alla Pagina',
     'attachments_edit_file' => 'Modifica File',
     'attachments_edit_file_name' => 'Nome File',
     'attachments_edit_drop_upload' => 'Rilascia file o clicca qui per caricare e sovrascrivere',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Sei sicuro di voler eliminare questa revisione?',
     'revision_restore_confirm' => 'Sei sicuro di voler ripristinare questa revisione? Il contenuto della pagina verrà rimpiazzato.',
     'revision_delete_success' => 'Revisione cancellata',
-    'revision_cannot_delete_latest' => 'Impossibile eliminare l\'ultima revisione.'
+    'revision_cannot_delete_latest' => 'Impossibile eliminare l\'ultima revisione.',
+
+    // Copy view
+    'copy_consider' => 'Per favore, considerate quanto segue quando copiate il contenuto.',
+    'copy_consider_permissions' => 'Le impostazioni dei permessi personalizzati non saranno copiate.',
+    'copy_consider_owner' => 'Diventerai il proprietario di tutti i contenuti copiati.',
+    'copy_consider_images' => 'I file delle immagini delle pagine non saranno duplicati e le immagini originali manterranno la loro relazione con la pagina su cui sono state originariamente caricate.',
+    'copy_consider_attachments' => 'Gli allegati della pagina non saranno copiati.',
+    'copy_consider_access' => 'Un cambiamento di luogo, di proprietario o di autorizzazioni può far sì che questo contenuto sia accessibile a chi prima non aveva accesso.',
 ];
index 3e48ad762a942488b3c1d8bf99269fa2e3fb092a..2176b44baf97c5674ce221be0ebba020a62f278e 100755 (executable)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Impossibile trovare un indirizzo email per questo utente nei dati forniti dal sistema di autenticazione esterno',
     'saml_invalid_response_id' => 'La richiesta dal sistema di autenticazione esterno non è riconosciuta da un processo iniziato da questa applicazione. Tornare indietro dopo un login potrebbe causare questo problema.',
     'saml_fail_authed' => 'Accesso con :system non riuscito, il sistema non ha fornito l\'autorizzazione corretta',
+    'oidc_already_logged_in' => 'Hai già effettuato il login',
+    'oidc_user_not_registered' => 'L\'utente :name non è registrato e la registrazione automatica è disabilitata',
+    'oidc_no_email_address' => 'Impossibile trovare un indirizzo email, per questo utente, nei dati forniti dal sistema di autenticazione esterno',
+    'oidc_fail_authed' => 'Accesso con :system non riuscito, il sistema non ha fornito l\'autorizzazione',
     'social_no_action_defined' => 'Nessuna azione definita',
     'social_login_bad_response' => "Ricevuto error durante il login con :socialAccount : \n:error",
     'social_account_in_use' => 'Questo account :socialAccount è già utilizzato, prova a loggarti usando l\'opzione :socialAccount.',
@@ -83,20 +87,23 @@ return [
     '404_page_not_found' => 'Pagina Non Trovata',
     'sorry_page_not_found' => 'La pagina che stavi cercando non è stata trovata.',
     'sorry_page_not_found_permission_warning' => 'Se pensi che questa pagina possa esistere, potresti non avere i permessi per visualizzarla.',
+    'image_not_found' => 'Immagine non trovata',
+    'image_not_found_subtitle' => 'Spiacente, l\'immagine che stai cercando non è stata trovata.',
+    'image_not_found_details' => 'Se ti aspettavi che questa immagine esistesse, potrebbe essere stata cancellata.',
     'return_home' => 'Ritorna alla home',
     'error_occurred' => 'C\'è Stato un errore',
     'app_down' => ':appName è offline',
     'back_soon' => 'Ritornerà presto.',
 
     // API errors
-    'api_no_authorization_found' => 'No authorization token found on the request',
+    'api_no_authorization_found' => 'Nessun token di autorizzazione trovato nella richiesta',
     'api_bad_authorization_format' => 'Un token di autorizzazione è stato trovato nella richiesta, ma il formato sembra non corretto',
-    'api_user_token_not_found' => 'No matching API token was found for the provided authorization token',
-    'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect',
+    'api_user_token_not_found' => 'Nessun token API valido è stato trovato nel token di autorizzazione fornito',
+    'api_incorrect_token_secret' => 'Il token segreto fornito per il token API utilizzato non è corretto',
     'api_user_no_api_permission' => 'Il proprietario del token API utilizzato non ha il permesso di effettuare chiamate API',
-    'api_user_token_expired' => 'The authorization token used has expired',
+    'api_user_token_expired' => 'Il token di autorizzazione utilizzato è scaduto',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'Si è verificato un errore durante l\'invio di una e-mail di prova:',
 
 ];
index 604e02fe2c6b0a47298624b3db025737864a26af..7099d54f37d118cd043776b2cc06c70bd211bd53 100755 (executable)
@@ -8,8 +8,8 @@ return [
 
     'password' => 'La password deve avere almeno sei caratteri e corrispondere alla conferma.',
     'user' => "Non possiamo trovare un utente per quella mail.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Il token per reimpostare la password non è valido per questo indirizzo email.',
     'sent' => 'Ti abbiamo inviato via mail il link per reimpostare la password!',
-    'reset' => 'La tua password è stata resettata!',
+    'reset' => 'La tua password è stata reimpostata!',
 
 ];
index 4f0414bafac1187d7e7be1f607db026046d15cd2..b7c0c0e793184a59ef5311e9a6c8c53d9dd931da 100755 (executable)
@@ -37,11 +37,11 @@ return [
     'app_homepage' => 'Homepage Applicazione',
     'app_homepage_desc' => 'Seleziona una pagina da mostrare nella home anzichè quella di default. I permessi della pagina sono ignorati per quella selezionata.',
     'app_homepage_select' => 'Seleziona una pagina',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
-    'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_footer_links' => 'Link in basso',
+    'app_footer_links_desc' => 'Aggiungi link da mostrare in basso nel sito. Questi saranno visibili in fondo alla maggior parte delle pagine, incluse quelle che non richiedono un autenticazione. Puoi usare l\'etichetta "trans::<chiave>" per utilizzare le traduzioni implementate nella piattaforma. Esempio: usando "trans::common.privacy_policy" mostrerà il testo tradotto "Norme sulla privacy" e "trans::common.terms_of_service" mostrerà il testo tradotto "Condizioni del Servizio".',
+    'app_footer_links_label' => 'Etichetta del Link',
+    'app_footer_links_url' => 'URL del Link',
+    'app_footer_links_add' => 'Aggiungi Link in basso',
     'app_disable_comments' => 'Disattiva commenti',
     'app_disable_comments_toggle' => 'Disabilita commenti',
     'app_disable_comments_desc' => 'Disabilita i commenti su tutte le pagine nell\'applicazione. I commenti esistenti non sono mostrati. ',
@@ -49,7 +49,7 @@ return [
     // Color settings
     'content_colors' => 'Colori del contenuto',
     'content_colors_desc' => 'Imposta i colori per tutti gli elementi nella gerarchia della pagina. È raccomandato scegliere colori con una luminosità simile a quelli di default per una maggiore leggibilità.',
-    'bookshelf_color' => 'Colore delle libreria',
+    'bookshelf_color' => 'Colore della libreria',
     'book_color' => 'Colore del libro',
     'chapter_color' => 'Colore del capitolo',
     'page_color' => 'Colore della Pagina',
@@ -61,7 +61,7 @@ return [
     'reg_enable_toggle' => 'Abilita registrazione',
     'reg_enable_desc' => 'Quando la registrazione è abilitata, l\utente sarà in grado di registrarsi all\'applicazione. Al momento della registrazione gli verrà associato un ruolo utente predefinito.',
     'reg_default_role' => 'Ruolo predefinito dopo la registrazione',
-    'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.',
+    'reg_enable_external_warning' => 'L\'opzione precedente viene ignorata se l\'autenticazione esterna tramite LDAP o SAML è attiva. Se l\'autenticazione (effettuata sul sistema esterno) sarà valida, gli account di eventuali membri non registrati saranno creati in automatico.',
     'reg_email_confirmation' => 'Conferma Email',
     'reg_email_confirmation_toggle' => 'Richiedi conferma email',
     'reg_confirm_email_desc' => 'Se la restrizione per dominio è usata la conferma della mail sarà richiesta e la scelta ignorata.',
@@ -72,8 +72,8 @@ return [
     // Maintenance settings
     'maint' => 'Manutenzione',
     'maint_image_cleanup' => 'Pulizia Immagini',
-    'maint_image_cleanup_desc' => "Esegue la scansione del contenuto delle pagine e delle revisioni per verificare quali immagini e disegni sono attualmente in uso e quali immagini sono ridondanti. Assicurati di creare backup completo del database e delle immagini prima di eseguire la pulizia.",
-    'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
+    'maint_image_cleanup_desc' => 'Esegue la scansione del contenuto delle pagine e delle revisioni per verificare quali immagini e disegni sono attualmente in uso e quali immagini sono ridondanti. Assicurati di creare backup completo del database e delle immagini prima di eseguire la pulizia.',
+    'maint_delete_images_only_in_revisions' => 'Elimina anche le immagini che esistono solo nelle vecchie revisioni della pagina',
     'maint_image_cleanup_run' => 'Esegui Pulizia',
     'maint_image_cleanup_warning' => ':count immagini potenzialmente inutilizzate sono state trovate. Sei sicuro di voler eliminare queste immagini?',
     'maint_image_cleanup_success' => ':count immagini potenzialmente inutilizzate trovate e eliminate!',
@@ -85,41 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Email di Test',
     'maint_send_test_email_mail_greeting' => 'L\'invio delle email sembra funzionare!',
     'maint_send_test_email_mail_text' => 'Congratulazioni! Siccome hai ricevuto questa notifica email, le tue impostazioni sembrano essere configurate correttamente.',
-    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
+    'maint_recycle_bin_desc' => 'Le librerie, i libri, i capitoli e le pagine cancellati vengono inviati al cestino in modo che possano essere ripristinati o eliminati definitivamente. Gli elementi più vecchi nel cestino possono essere automaticamente rimossi dopo un certo periodo, a seconda della configurazione del sistema.',
     'maint_recycle_bin_open' => 'Apri il Cestino',
 
     // Recycle Bin
     'recycle_bin' => 'Cestino',
-    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_desc' => 'Qui è possibile ripristinare gli elementi che sono stati eliminati o scegliere di rimuoverli definitivamente dal sistema. Questo elenco non è filtrato a differenza di elenchi di attività simili nel sistema in cui vengono applicati i filtri autorizzazioni.',
+    'recycle_bin_deleted_item' => 'Elimina Elemento',
+    'recycle_bin_deleted_parent' => 'Superiore',
     'recycle_bin_deleted_by' => 'Cancellato da',
     'recycle_bin_deleted_at' => 'Orario Cancellazione',
     'recycle_bin_permanently_delete' => 'Elimina Definitivamente',
     'recycle_bin_restore' => 'Ripristina',
-    'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
+    'recycle_bin_contents_empty' => 'Al momento il cestino è vuoto',
     'recycle_bin_empty' => 'Svuota Cestino',
-    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
-    'recycle_bin_restore_list' => 'Items to be Restored',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
-    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin_empty_confirm' => 'Questa operazione cancellerà definitivamente tutti gli elementi presenti nel cestino, inclusi i contenuti relativi a ciascun elemento. Sei sicuro di voler svuotare il cestino?',
+    'recycle_bin_destroy_confirm' => 'Questa operazione eliminerà permanentemente questo elemento (insieme a tutti i relativi elementi elencati qui sotto) dal sistema e non sarà più possibile recuperarlo. Sei sicuro di voler eliminare permanentemente questo elemento?',
+    'recycle_bin_destroy_list' => 'Elementi da Eliminare definitivamente',
+    'recycle_bin_restore_list' => 'Elementi da Ripristinare',
+    'recycle_bin_restore_confirm' => 'Questa azione ripristinerà l\'elemento eliminato, compresi gli elementi figli, nella loro posizione originale. Se la posizione originale è stata eliminata, ed è ora nel cestino, anche l\'elemento padre dovrà essere ripristinato.',
+    'recycle_bin_restore_deleted_parent' => 'L\'elemento padre di questo elemento è stato eliminato. Questo elemento rimarrà eliminato fino a che l\'elemento padre non sarà ripristinato.',
+    'recycle_bin_restore_parent' => 'Ripristina Superiore',
+    'recycle_bin_destroy_notification' => 'Eliminati :count elementi dal cestino.',
+    'recycle_bin_restore_notification' => 'Ripristinati :count elementi dal cestino.',
 
     // Audit Log
-    'audit' => 'Audit Log',
-    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'audit_event_filter' => 'Event Filter',
-    'audit_event_filter_no_filter' => 'No Filter',
-    'audit_deleted_item' => 'Deleted Item',
-    'audit_deleted_item_name' => 'Name: :name',
+    'audit' => 'Registro di Controllo',
+    'audit_desc' => 'Questo registro di controllo mostra la lista delle attività registrate dal sistema. Questa lista, a differenza di altre liste del sistema a cui vengono applicate dei filtri, è integrale.',
+    'audit_event_filter' => 'Filtra Eventi',
+    'audit_event_filter_no_filter' => 'Nessun Filtro',
+    'audit_deleted_item' => 'Elimina Elemento',
+    'audit_deleted_item_name' => 'Nome: :name',
     'audit_table_user' => 'Utente',
     'audit_table_event' => 'Evento',
-    'audit_table_related' => 'Related Item or Detail',
-    'audit_table_date' => 'Activity Date',
-    'audit_date_from' => 'Date Range From',
-    'audit_date_to' => 'Date Range To',
+    'audit_table_related' => 'Elemento o Dettaglio correlato',
+    'audit_table_ip' => 'Indirizzo IP',
+    'audit_table_date' => 'Data attività',
+    'audit_date_from' => 'Dalla data',
+    'audit_date_to' => 'Alla data',
 
     // Role Settings
     'roles' => 'Ruoli',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Dettagli Ruolo',
     'role_name' => 'Nome Ruolo',
     'role_desc' => 'Breve Descrizione del Ruolo',
+    'role_mfa_enforced' => 'Richiesta autenticazione multi-fattore',
     'role_external_auth_id' => 'ID Autenticazione Esterna',
     'role_system' => 'Permessi di Sistema',
     'role_manage_users' => 'Gestire gli utenti',
@@ -143,10 +147,11 @@ return [
     'role_manage_entity_permissions' => 'Gestire tutti i permessi di libri, capitoli e pagine',
     'role_manage_own_entity_permissions' => 'Gestire i permessi sui propri libri, capitoli e pagine',
     'role_manage_page_templates' => 'Gestisci template pagine',
-    'role_access_api' => 'Access system API',
+    'role_access_api' => 'API sistema d\'accesso',
     'role_manage_settings' => 'Gestire impostazioni app',
+    'role_export_content' => 'Esporta contenuto',
     'role_asset' => 'Permessi Entità',
-    '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.',
+    '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 di default alle entità. I permessi nei 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_all' => 'Tutti',
@@ -162,26 +167,26 @@ return [
     'user_profile' => 'Profilo Utente',
     'users_add_new' => 'Aggiungi Nuovo Utente',
     'users_search' => 'Cerca Utenti',
-    'users_latest_activity' => 'Latest Activity',
+    'users_latest_activity' => 'Ultima Attività',
     'users_details' => 'Dettagli Utente',
     'users_details_desc' => 'Imposta un nome e un indirizzo email per questo utente. L\'indirizzo email verrà utilizzato per accedere all\'applicazione.',
     'users_details_desc_no_email' => 'Imposta un nome per questo utente così gli altri possono riconoscerlo.',
     'users_role' => 'Ruoli Utente',
     'users_role_desc' => 'Seleziona a quali ruoli verrà assegnato questo utente. Se un utente è assegnato a più ruoli riceverà tutte le abilità dei ruoli assegnati.',
     'users_password' => 'Password Utente',
-    'users_password_desc' => 'Imposta una password utilizzata per accedere all\'applicazione. Deve essere lunga almeno 6 caratteri.',
+    'users_password_desc' => 'Imposta una password usata per accedere all\'applicazione. Deve essere lunga almeno 8 caratteri.',
     'users_send_invite_text' => 'Puoi scegliere di inviare a questo utente un\'email di invito che permette loro di impostare la propria password altrimenti puoi impostare la password tu stesso.',
     'users_send_invite_option' => 'Invia email di invito',
     'users_external_auth_id' => 'ID Autenticazioni Esterna',
-    'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
+    'users_external_auth_id_desc' => 'Questo è l\'ID usato per abbinare questo utente quando si comunica con il sistema di autenticazione esterno.',
     'users_password_warning' => 'Riempi solo se desideri cambiare la tua password:',
     'users_system_public' => 'Questo utente rappresente qualsiasi ospite che visita il sito. Non può essere usato per effettuare il login ma è assegnato automaticamente.',
     'users_delete' => 'Elimina Utente',
     'users_delete_named' => 'Elimina l\'utente :userName',
     'users_delete_warning' => 'Questo eliminerà completamente l\'utente \':userName\' dal sistema.',
     'users_delete_confirm' => 'Sei sicuro di voler eliminare questo utente?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
+    'users_migrate_ownership' => 'Cambia Proprietario',
+    'users_migrate_ownership_desc' => 'Seleziona qui un utente se vuoi che un altro utente diventi il proprietario di tutti gli elementi attualmente di proprietà di questo utente.',
     'users_none_selected' => 'Nessun utente selezionato',
     'users_delete_success' => 'Utente rimosso con successo',
     'users_edit' => 'Modifica Utente',
@@ -197,33 +202,65 @@ return [
     'users_social_disconnect' => 'Disconnetti Account',
     'users_social_connected' => 'L\'account :socialAccount è stato connesso correttamente al tuo profilo.',
     'users_social_disconnected' => 'L\'account :socialAccount è stato disconnesso correttamente dal tuo profilo.',
-    'users_api_tokens' => 'API Tokens',
-    'users_api_tokens_none' => 'No API tokens have been created for this user',
+    'users_api_tokens' => 'Token API',
+    'users_api_tokens_none' => 'Nessun token API è stato creato per questo utente',
     'users_api_tokens_create' => 'Crea Token',
     'users_api_tokens_expires' => 'Scade',
-    'users_api_tokens_docs' => 'API Documentation',
+    'users_api_tokens_docs' => 'Documentazione API',
+    'users_mfa' => 'Autenticazione multi-fattore',
+    'users_mfa_desc' => 'Imposta l\'autenticazione multi-fattore come misura di sicurezza aggiuntiva per il tuo account.',
+    'users_mfa_x_methods' => ':count metodo configurato|:count metodi configurati',
+    'users_mfa_configure' => 'Configura metodi',
 
     // API Tokens
     'user_api_token_create' => 'Crea Token API',
     'user_api_token_name' => 'Nome',
-    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
+    'user_api_token_name_desc' => 'Assegna al tuo token un nome leggibile per ricordarne la funzionalità in futuro.',
     'user_api_token_expiry' => 'Data di scadenza',
-    'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
-    'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
-    'user_api_token_create_success' => 'API token successfully created',
-    'user_api_token_update_success' => 'API token successfully updated',
+    'user_api_token_expiry_desc' => 'Imposta una data di scadenza per questo token. Dopo questa data, le richieste che utilizzeranno questo token non funzioneranno più. Lasciando questo campo vuoto si imposterà la scadenza tra 100 anni.',
+    'user_api_token_create_secret_message' => 'Immediatamente dopo aver creato questo token, un "Token ID" e un "Segreto Token" saranno generati e mostrati. Il segreto verrà mostrato unicamente questa volta, assicurati, quindi, di copiare il valore in un posto sicuro prima di procedere.',
+    'user_api_token_create_success' => 'Token API creato correttamente',
+    'user_api_token_update_success' => 'Token API aggiornato correttamente',
     'user_api_token' => 'Token API',
     'user_api_token_id' => 'Token ID',
-    'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
-    'user_api_token_secret' => 'Token Secret',
-    'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.',
+    'user_api_token_id_desc' => 'Questo è un identificativo non modificabile generato dal sistema per questo token e che sarà necessario fornire per le richieste tramite API.',
+    'user_api_token_secret' => 'Token Segreto',
+    'user_api_token_secret_desc' => 'Questo è un segreto generato dal sistema per questo token che sarà necessario fornire per le richieste via API. Questo valore sarà visibile unicamente in questo momento pertanto copialo in un posto sicuro.',
     'user_api_token_created' => 'Token Aggiornato :timeAgo',
     'user_api_token_updated' => 'Token Aggiornato :timeAgo',
     'user_api_token_delete' => 'Elimina Token',
-    'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
+    'user_api_token_delete_warning' => 'Questa operazione eliminerà irreversibilmente dal sistema il token API denominato \':tokenName\'.',
     'user_api_token_delete_confirm' => 'Sei sicuri di voler eliminare questo token API?',
     'user_api_token_delete_success' => 'Token API eliminato correttamente',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Crea Nuovo Webhook',
+    'webhooks_none_created' => 'Nessun webhook è stato creato.',
+    'webhooks_edit' => 'Modifica Webhook',
+    'webhooks_save' => 'Salva Webhook',
+    'webhooks_details' => 'Dettagli Webhook',
+    'webhooks_details_desc' => 'Fornire un nome di facile utilizzo e un endpoint POST come posizione per i dati del webhook da inviare.',
+    'webhooks_events' => 'Eventi Webhook',
+    'webhooks_events_desc' => 'Seleziona tutti gli eventi che dovrebbero attivare questo webhook da chiamare.',
+    'webhooks_events_warning' => 'Tieni presente che questi eventi saranno attivati per tutti gli eventi selezionati, anche se vengono applicati permessi personalizzati. Assicurarsi che l\'uso di questo webhook non esporrà contenuti riservati.',
+    'webhooks_events_all' => 'Tutti gli eventi di sistema',
+    'webhooks_name' => 'Nome Webhook',
+    'webhooks_timeout' => 'Timeout Richiesta Webhook (Secondi)',
+    'webhooks_endpoint' => 'Endpoint Webhook',
+    'webhooks_active' => 'Webhook Attivo',
+    'webhook_events_table_header' => 'Eventi',
+    'webhooks_delete' => 'Elimina Webhook',
+    'webhooks_delete_warning' => 'Questo eliminerà completamente questo webhook, con il nome \':webhookName\', dal sistema.',
+    'webhooks_delete_confirm' => 'Sei sicuro di voler eliminare questo webhook?',
+    'webhooks_format_example' => 'Esempio Di Formato Webhook',
+    'webhooks_format_example_desc' => 'I dati Webhook vengono inviati come richiesta POST all\'endpoint configurato come JSON seguendo il formato sottostante. Le proprietà "related_item" e "url" sono opzionali e dipenderanno dal tipo di evento attivato.',
+    'webhooks_status' => 'Stato Webhook',
+    'webhooks_last_called' => 'Ultima Chiamata:',
+    'webhooks_last_errored' => 'Ultimo Errore:',
+    'webhooks_last_error_message' => 'Ultimo Messaggio Di Errore:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -232,20 +269,23 @@ return [
         'ar' => 'العربية',
         'bg' => 'Bǎlgarski',
         'bs' => 'Bosanski',
-        'ca' => 'Català',
+        'ca' => 'Catalano',
         'cs' => 'Česky',
         'da' => 'Danese',
         'de' => 'Deutsch (Sie)',
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index bb602619cea562e94c620f9d7f7ddcde602b640d..62f0701198d9aebb52ee1687d24ab4b885ec5946 100755 (executable)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute deve contenere solo lettere, numeri e meno.',
     'alpha_num'            => ':attribute deve contenere solo lettere e numeri.',
     'array'                => ':attribute deve essere un array.',
+    'backup_codes'         => 'Il codice fornito non è valido o è già stato utilizzato.',
     'before'               => ':attribute deve essere una data prima del :date.',
     'between'              => [
         'numeric' => 'Il campo :attribute deve essere tra :min e :max.',
@@ -89,7 +90,7 @@ return [
     'required_without'     => 'Il campo :attribute è richiesto quando :values non è presente.',
     'required_without_all' => 'Il campo :attribute è richiesto quando nessuno dei :values sono presenti.',
     'same'                 => ':attribute e :other devono corrispondere.',
-    'safe_url'             => 'The provided link may not be safe.',
+    'safe_url'             => 'Il link inserito potrebbe non essere sicuro.',
     'size'                 => [
         'numeric' => 'Il campo :attribute deve essere :size.',
         'file'    => 'Il campo :attribute deve essere :size kilobytes.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute deve essere una stringa.',
     'timezone'             => ':attribute deve essere una zona valida.',
+    'totp'                 => 'Il codice fornito non è valido o è scaduto.',
     'unique'               => ':attribute è già preso.',
     'url'                  => 'Il formato :attribute non è valido.',
     'uploaded'             => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.',
index b1995a654f5b8c85b8ef39cdbf9c9fb85762fcc5..65fef973131c83d1ff980e2b3810a0eb1ed4efa5 100644 (file)
@@ -33,17 +33,33 @@ return [
     'book_delete'                 => 'がブックを削除:',
     'book_delete_notification'    => 'ブックを削除しました',
     'book_sort'                   => 'がブックの並び順を変更:',
-    'book_sort_notification'      => '並び順を変更しました',
+    'book_sort_notification'      => 'ブックが再度並び変えられました',
 
     // Bookshelves
-    'bookshelf_create'            => '本棚を作成:',
+    'bookshelf_create'            => '本棚を作成:',
     'bookshelf_create_notification'    => '本棚を作成しました',
-    'bookshelf_update'                 => '本棚を更新:',
+    'bookshelf_update'                 => '本棚を更新:',
     'bookshelf_update_notification'    => '本棚を更新しました',
-    'bookshelf_delete'                 => 'ã\83\96ã\83\83ã\82¯ã\81\8cå\89\8aé\99¤ã\81\95ã\82\8cã\81¾ã\81\97ã\81\9fã\80\82',
+    'bookshelf_delete'                 => 'ã\81\8cæ\9c¬æ£\9aã\82\92å\89\8aé\99¤:',
     'bookshelf_delete_notification'    => '本棚を削除しました',
 
+    // Favourites
+    'favourite_add_notification' => '":name"がお気に入りに追加されました',
+    'favourite_remove_notification' => '":name"がお気に入りから削除されました',
+
+    // MFA
+    'mfa_setup_method_notification' => '多要素認証が正常に設定されました',
+    'mfa_remove_method_notification' => '多要素認証が正常に解除されました',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
+
     // Other
-    'commented_on'                => 'ã\82³ã\83¡ã\83³ã\83\88ã\81\99ã\82\8b',
-    'permissions_update'          => 'updated permissions',
+    'commented_on'                => 'ã\81\8cã\82³ã\83¡ã\83³ã\83\88:',
+    'permissions_update'          => 'が権限を更新:',
 ];
index 6163a5fc1f739b171b332661d7112fb282a13d4e..9c8d36669be4ff97611b5ed7727792996a892e02 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'メールアドレス',
     'password' => 'パスワード',
     'password_confirm' => 'パスワード (確認)',
-    'password_hint' => '7文字以上である必要があります',
+    'password_hint' => '8文字以上で設定する必要があります',
     'forgot_password' => 'パスワードをお忘れですか?',
     'remember_me' => 'ログイン情報を保存する',
     'ldap_email_hint' => 'このアカウントで使用するEメールアドレスを入力してください。',
@@ -38,25 +38,23 @@ return [
     'registration_email_domain_invalid' => 'このEmailドメインでの登録は許可されていません。',
     'register_success' => '登録が完了し、ログインできるようになりました!',
 
-
     // Password Reset
     'reset_password' => 'パスワードリセット',
     'reset_password_send_instructions' => '以下にEメールアドレスを入力すると、パスワードリセットリンクが記載されたメールが送信されます。',
     'reset_password_send_button' => 'リセットリンクを送信',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'メールアドレスがシステムで見つかった場合、パスワードリセットリンクが:emailに送信されます。',
     'reset_password_success' => 'パスワードがリセットされました。',
     'email_reset_subject' => ':appNameのパスワードをリセット',
     'email_reset_text' => 'このメールは、パスワードリセットがリクエストされたため送信されています。',
     'email_reset_not_requested' => 'もしパスワードリセットを希望しない場合、操作は不要です。',
 
-
     // Email Confirmation
     'email_confirm_subject' => ':appNameのメールアドレス確認',
     'email_confirm_greeting' => ':appNameへ登録してくださりありがとうございます!',
     'email_confirm_text' => '以下のボタンを押し、メールアドレスを確認してください:',
     'email_confirm_action' => 'メールアドレスを確認',
     'email_confirm_send_error' => 'Eメールの確認が必要でしたが、システム上でEメールの送信ができませんでした。管理者に連絡し、Eメールが正しく設定されていることを確認してください。',
-    'email_confirm_success' => 'Eメールアドレスが確認されました。',
+    'email_confirm_success' => 'メールアドレスが確認されました!このメールアドレスでログインできるようになりました。',
     'email_confirm_resent' => '確認メールを再送信しました。受信トレイを確認してください。',
 
     'email_not_confirmed' => 'Eメールアドレスが確認できていません',
@@ -66,12 +64,47 @@ return [
     'email_not_confirmed_resend_button' => '確認メールを再送信',
 
     // User Invite
-    'user_invite_email_subject' => 'You have been invited to join :appName!',
-    'user_invite_email_greeting' => 'An account has been created for you on :appName.',
-    'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
-    'user_invite_email_action' => 'Set Account Password',
-    'user_invite_page_welcome' => 'Welcome to :appName!',
-    'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
-    'user_invite_page_confirm_button' => 'Confirm Password',
-    'user_invite_success' => 'Password set, you now have access to :appName!'
-];
\ No newline at end of file
+    'user_invite_email_subject' => ':appNameに招待されました!',
+    'user_invite_email_greeting' => ':appNameにあなたのアカウントが作成されました。',
+    'user_invite_email_text' => 'アカウントのパスワードを設定してアクセスできるようにするため、下のボタンをクリックしてください:',
+    'user_invite_email_action' => 'アカウントのパスワード設定',
+    'user_invite_page_welcome' => ':appNameへようこそ!',
+    'user_invite_page_text' => 'アカウントの設定を完了してアクセスするには、今後の訪問時に:appNameにログインするためのパスワードを設定する必要があります。',
+    'user_invite_page_confirm_button' => 'パスワードを確定',
+    'user_invite_success_login' => 'パスワードが設定されました。設定したパスワードで:appNameにログインできるようになりました!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => '多要素認証を設定',
+    'mfa_setup_desc' => 'アカウントのセキュリティを強化するために、多要素認証を設定してください。',
+    'mfa_setup_configured' => '既に設定されています',
+    'mfa_setup_reconfigure' => '再設定',
+    'mfa_setup_remove_confirmation' => 'この多要素認証方法を削除してもよろしいですか?',
+    'mfa_setup_action' => '設定',
+    'mfa_backup_codes_usage_limit_warning' => 'バックアップコードは残り5つ以下です。アカウントのロックアウトを防ぐため、コードがなくなる前に新しいセットを生成して保存してください。',
+    'mfa_option_totp_title' => 'モバイルアプリ',
+    'mfa_option_totp_desc' => '多要素認証を使用するには、Google Authenticator、Authy、Microsoft AuthenticatorなどのTOTPをサポートするモバイルアプリケーションが必要です。',
+    'mfa_option_backup_codes_title' => 'バックアップコード',
+    'mfa_option_backup_codes_desc' => '本人確認のために入力する、一度しか使えないバックアップコードを安全に保存します。',
+    'mfa_gen_confirm_and_enable' => '確認して有効化',
+    'mfa_gen_backup_codes_title' => 'バックアップコードの設定',
+    'mfa_gen_backup_codes_desc' => '以下のコードのリストを安全な場所に保管してください。システムにアクセスする際、コードのいずれかを第二の認証手段として使用できます。',
+    'mfa_gen_backup_codes_download' => 'コードをダウンロード',
+    'mfa_gen_backup_codes_usage_warning' => '各コードは一度だけ使用できます',
+    'mfa_gen_totp_title' => 'モバイルアプリの設定',
+    'mfa_gen_totp_desc' => '多要素認証を使用するには、Google Authenticator、Authy、Microsoft AuthenticatorなどのTOTPをサポートするモバイルアプリケーションが必要です。',
+    'mfa_gen_totp_scan' => '利用したい認証アプリで以下のQRコードをスキャンしてください。',
+    'mfa_gen_totp_verify_setup' => '設定を検証',
+    'mfa_gen_totp_verify_setup_desc' => '認証アプリで生成されたコードを下の入力ボックスに入力し、すべてが機能していることを確認してください。',
+    'mfa_gen_totp_provide_code_here' => 'アプリが生成したコードを入力',
+    'mfa_verify_access' => 'アクセスを確認',
+    'mfa_verify_access_desc' => 'このユーザーアカウントはアクセスを許可する前に追加の検証レベルで本人確認を行う必要があります。続行するには、設定されているいずれかの手段で検証してください。',
+    'mfa_verify_no_methods' => '手段が設定されていません',
+    'mfa_verify_no_methods_desc' => 'アカウントの多要素認証手段が見つかりませんでした。アクセスする前に、少なくとも1つの手段を設定する必要があります。',
+    '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' => '多要素認証が構成されました。設定された手段を利用して再度ログインしてください。',
+];
index 836b963011a66e4fb44c8880f5b7c7feb7748d30..70b9611004a55b6ede12eec11ef4bbac2b701fd9 100644 (file)
@@ -11,72 +11,85 @@ return [
     'save' => '保存',
     'continue' => '続ける',
     'select' => '選択',
-    'toggle_all' => 'Toggle All',
+    'toggle_all' => '一括切替',
     'more' => 'その他',
 
     // Form Labels
     'name' => '名称',
     'description' => '概要',
     'role' => '権限',
-    'cover_image' => 'Cover image',
-    'cover_image_description' => 'この画像は約 300x170px をする必要があります。',
-    
+    'cover_image' => 'カバー画像',
+    'cover_image_description' => 'この画像はおよそ440x250pxの大きさが必要です。',
+
     // Actions
     'actions' => '実行',
     'view' => '表示',
-    'view_all' => 'View All',
+    'view_all' => 'すべて表示',
     'create' => '作成',
     'update' => '更新',
     'edit' => '編集',
     'sort' => '並び順',
     'move' => '移動',
-    'copy' => 'Copy',
+    'copy' => 'コピー',
     'reply' => '返信',
     'delete' => '削除',
-    'delete_confirm' => 'Confirm Deletion',
+    'delete_confirm' => '確認して削除',
     'search' => '検索',
     'search_clear' => '検索をクリア',
     'reset' => 'リセット',
     'remove' => '削除',
     'add' => '追加',
-    'fullscreen' => 'Fullscreen',
+    'configure' => 'Configure',
+    'fullscreen' => '全画面',
+    'favourite' => 'お気に入り',
+    'unfavourite' => 'お気に入りから削除',
+    'next' => '次へ',
+    'previous' => '前へ',
+    'filter_active' => '有効なフィルター:',
+    'filter_clear' => 'フィルターを解除',
 
     // Sort Options
-    'sort_options' => 'Sort Options',
-    'sort_direction_toggle' => 'Sort Direction Toggle',
-    'sort_ascending' => 'Sort Ascending',
-    'sort_descending' => 'Sort Descending',
-    'sort_name' => 'Name',
-    'sort_default' => 'Default',
-    'sort_created_at' => 'Created Date',
-    'sort_updated_at' => 'Updated Date',
+    'sort_options' => '並べ替えオプション',
+    'sort_direction_toggle' => '並べ替え方向の切り替え',
+    'sort_ascending' => '昇順に並べ替え',
+    'sort_descending' => '降順に並べ替え',
+    'sort_name' => '名前',
+    'sort_default' => 'デフォルト',
+    'sort_created_at' => '作成日',
+    'sort_updated_at' => '更新日',
 
     // Misc
     'deleted_user' => '削除済みユーザ',
     'no_activity' => '表示するアクティビティがありません',
     'no_items' => 'アイテムはありません',
     'back_to_top' => '上に戻る',
+    'skip_to_main_content' => 'メインコンテンツへスキップ',
     'toggle_details' => '概要の表示切替',
     'toggle_thumbnails' => 'Toggle Thumbnails',
     'details' => '詳細',
     'grid_view' => 'グリッド形式',
     'list_view' => 'リスト形式',
-    'default' => 'Default',
-    'breadcrumb' => 'Breadcrumb',
+    'default' => 'デフォルト',
+    'breadcrumb' => 'パンくずリスト',
+    'status' => '状態',
+    'status_active' => '有効',
+    'status_inactive' => '無効',
+    'never' => '該当なし',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
-    'profile_menu' => 'Profile Menu',
+    'header_menu_expand' => 'ヘッダーメニューを展開',
+    'profile_menu' => 'プロフィールメニュー',
     'view_profile' => 'プロフィール表示',
     'edit_profile' => 'プロフィール編集',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'ダークモード',
+    'light_mode' => 'ライトモード',
 
     // Layout tabs
-    'tab_info' => 'Info',
-    'tab_info_label' => 'Tab: Show Secondary Information',
-    'tab_content' => 'Content',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_info' => '情報',
+    'tab_info_label' => 'タブ: サブコンテンツを表示',
+    'tab_content' => '内容',
+    'tab_content_label' => 'タブ: メインコンテンツを表示',
 
     // Email Content
     'email_action_help' => '":actionText" をクリックできない場合、以下のURLをコピーしブラウザで開いてください:',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'プライバシーポリシー',
+    'terms_of_service' => '利用規約',
 ];
index c4e44433787858f367e72133eba3dfab288b49e2..54a1092d2ac4f247c2c247d261b6aa8a54a1745b 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'さらに読み込む',
     'image_image_name' => '画像名',
     'image_delete_used' => 'この画像は以下のページで利用されています。',
-    'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
+    'image_delete_confirm_text' => 'この画像を削除してもよろしいですか?',
     'image_select_image' => '画像を選択',
     'image_dropzone' => '画像をドロップするか、クリックしてアップロード',
     'images_deleted' => '画像を削除しました',
index 8760b25a3f70877be28c6869d0e62a697853372c..e5ba8c673061bcbcd7e8c911e4a25261bb94ab4b 100644 (file)
@@ -11,7 +11,7 @@ return [
     'recently_updated_pages' => '最近更新されたページ',
     'recently_created_chapters' => '最近作成されたチャプター',
     'recently_created_books' => '最近作成されたブック',
-    'recently_created_shelves' => 'Recently Created Shelves',
+    'recently_created_shelves' => '最近作成された本棚',
     'recently_update' => '最近更新',
     'recently_viewed' => '閲覧履歴',
     'recent_activity' => 'アクティビティ',
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => '作成: :timeLength (:user)',
     'meta_updated' => '更新: :timeLength',
     'meta_updated_name' => '更新: :timeLength (:user)',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => '所有者: :user',
     'entity_select' => 'エンティティ選択',
     'images' => '画像',
     'my_recent_drafts' => '最近の下書き',
     'my_recently_viewed' => '閲覧履歴',
+    'my_most_viewed_favourites' => '最も閲覧したお気に入り',
+    'my_favourites' => 'お気に入り',
     'no_pages_viewed' => 'なにもページを閲覧していません',
     'no_pages_recently_created' => '最近作成されたページはありません',
     'no_pages_recently_updated' => '最近更新されたページはありません。',
@@ -34,13 +36,14 @@ return [
     'export_html' => 'Webページ',
     'export_pdf' => 'PDF',
     'export_text' => 'テキストファイル',
+    'export_md' => 'Markdown',
 
     // Permissions and restrictions
     'permissions' => '権限',
     'permissions_intro' => 'この設定は各ユーザの役割よりも優先して適用されます。',
     'permissions_enable' => 'カスタム権限設定を有効にする',
     'permissions_save' => '権限を保存',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => '所有者',
 
     // Search
     'search_results' => '検索結果',
@@ -49,19 +52,19 @@ return [
     'search_no_pages' => 'ページが見つかりませんでした。',
     'search_for_term' => ':term の検索結果',
     'search_more' => 'さらに表示',
-    'search_advanced' => 'Advanced Search',
-    'search_terms' => 'Search Terms',
+    'search_advanced' => '高度な検索',
+    'search_terms' => '検索語句',
     'search_content_type' => '種類',
     'search_exact_matches' => '完全一致',
     'search_tags' => 'タグ検索',
-    'search_options' => 'Options',
+    'search_options' => 'オプション',
     'search_viewed_by_me' => '自分が閲覧したことがある',
     'search_not_viewed_by_me' => '自分が閲覧したことがない',
     'search_permissions_set' => '権限が設定されている',
     'search_created_by_me' => '自分が作成した',
     'search_updated_by_me' => '自分が更新した',
-    'search_owned_by_me' => 'Owned by me',
-    'search_date_options' => 'Date Options',
+    'search_owned_by_me' => '自分が所有している',
+    'search_date_options' => '日付オプション',
     'search_updated_before' => '以前に更新',
     'search_updated_after' => '以降に更新',
     'search_created_before' => '以前に作成',
@@ -70,48 +73,49 @@ return [
     'search_update' => 'フィルタを更新',
 
     // Shelves
-    'shelf' => 'Shelf',
-    'shelves' => 'Shelves',
-    'x_shelves' => ':count Shelf|:count Shelves',
-    'shelves_long' => 'Bookshelves',
-    'shelves_empty' => 'No shelves have been created',
-    'shelves_create' => 'Create New Shelf',
-    'shelves_popular' => 'Popular Shelves',
-    'shelves_new' => 'New Shelves',
-    'shelves_new_action' => 'New Shelf',
-    'shelves_popular_empty' => 'The most popular shelves will appear here.',
-    'shelves_new_empty' => 'The most recently created shelves will appear here.',
-    'shelves_save' => 'Save Shelf',
-    'shelves_books' => 'Books on this shelf',
-    'shelves_add_books' => 'Add books to this shelf',
-    'shelves_drag_books' => 'Drag books here to add them to this shelf',
-    'shelves_empty_contents' => 'This shelf has no books assigned to it',
-    'shelves_edit_and_assign' => 'Edit shelf to assign books',
-    'shelves_edit_named' => 'Edit Bookshelf :name',
-    'shelves_edit' => 'Edit Bookshelf',
-    'shelves_delete' => 'Delete Bookshelf',
-    'shelves_delete_named' => 'Delete Bookshelf :name',
-    'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
-    'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
-    'shelves_permissions' => 'Bookshelf Permissions',
-    'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
-    'shelves_permissions_active' => 'Bookshelf Permissions Active',
-    'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
-    'shelves_copy_permissions' => 'Copy Permissions',
-    'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
-    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
+    'shelf' => '本棚',
+    'shelves' => '本棚',
+    'x_shelves' => ':count 本棚|:count 本棚',
+    'shelves_long' => '本棚',
+    'shelves_empty' => '本棚が作成されていません',
+    'shelves_create' => '新しい本棚を作成',
+    'shelves_popular' => '人気の本棚',
+    'shelves_new' => '新しい本棚',
+    'shelves_new_action' => '新しい本棚',
+    'shelves_popular_empty' => 'ここに人気の本棚が表示されます。',
+    'shelves_new_empty' => '最近作成された本棚がここに表示されます。',
+    'shelves_save' => '本棚を保存',
+    'shelves_books' => 'この本棚のブック',
+    'shelves_add_books' => 'この本棚にブックを追加',
+    'shelves_drag_books' => 'ブックをここにドラッグすると本棚に追加されます',
+    'shelves_empty_contents' => 'この本棚にはブックが割り当てられていません。',
+    'shelves_edit_and_assign' => '本棚を編集してブックを割り当てる',
+    'shelves_edit_named' => '本棚「:name」を編集',
+    'shelves_edit' => '本棚を編集',
+    'shelves_delete' => '本棚を削除',
+    'shelves_delete_named' => '本棚「:name」を削除',
+    'shelves_delete_explain' => "これにより、この本棚「:name」が削除されます。含まれているブックは削除されません。",
+    'shelves_delete_confirmation' => '本当にこの本棚を削除してよろしいですか?',
+    'shelves_permissions' => '本棚の権限',
+    'shelves_permissions_updated' => '本棚の権限を更新しました',
+    'shelves_permissions_active' => '本棚の権限は有効です',
+    'shelves_permissions_cascade_warning' => '本棚の権限は含まれる本には自動的に継承されません。これは、1つのブックが複数の本棚に存在する可能性があるためです。ただし、以下のオプションを使用すると権限を子ブックにコピーできます。',
+    'shelves_copy_permissions_to_books' => 'ブックに権限をコピー',
+    'shelves_copy_permissions' => '権限をコピー',
+    'shelves_copy_permissions_explain' => 'これにより、この本棚の現在の権限設定を本棚に含まれるすべてのブックに適用します。有効にする前に、この本棚の権限への変更が保存されていることを確認してください。',
+    'shelves_copy_permission_success' => '本棚の権限が:count個のブックにコピーされました',
 
     // Books
-    'book' => 'Book',
+    'book' => 'ブック',
     'books' => 'ブック',
     'x_books' => ':count ブック',
     'books_empty' => 'まだブックは作成されていません',
     'books_popular' => '人気のブック',
     'books_recent' => '最近のブック',
     'books_new' => '新しいブック',
-    'books_new_action' => 'New Book',
+    'books_new_action' => '新しいブック',
     'books_popular_empty' => 'ここに人気のブックが表示されます。',
-    'books_new_empty' => 'The most recently created books will appear here.',
+    'books_new_empty' => '最近作成されたブックがここに表示されます。',
     'books_create' => '新しいブックを作成',
     'books_delete' => 'ブックを削除',
     'books_delete_named' => 'ブック「:bookName」を削除',
@@ -131,14 +135,16 @@ return [
     'books_search_this' => 'このブックから検索',
     'books_navigation' => '目次',
     'books_sort' => '並び順を変更',
-    'books_sort_named' => 'ã\83\96ã\83\83ã\82¯ã\80\8c:bookNameã\80\8dã\82\92並ã\81³替え',
-    'books_sort_name' => 'Sort by Name',
-    'books_sort_created' => 'Sort by Created Date',
-    'books_sort_updated' => 'Sort by Updated Date',
-    'books_sort_chapters_first' => 'Chapters First',
-    'books_sort_chapters_last' => 'Chapters Last',
+    'books_sort_named' => 'ã\83\96ã\83\83ã\82¯ã\80\8c:bookNameã\80\8dã\82\92並ã\81¹替え',
+    'books_sort_name' => '名前で並べ替え',
+    'books_sort_created' => '作成日で並べ替え',
+    'books_sort_updated' => '更新日で並べ替え',
+    'books_sort_chapters_first' => 'チャプターを先に',
+    'books_sort_chapters_last' => 'チャプターを後に',
     'books_sort_show_other' => '他のブックを表示',
     'books_sort_save' => '並び順を保存',
+    'books_copy' => 'ブックをコピー',
+    'books_copy_success' => 'ブックが正常にコピーされました',
 
     // Chapters
     'chapter' => 'チャプター',
@@ -149,7 +155,7 @@ return [
     'chapters_create' => 'チャプターを作成',
     'chapters_delete' => 'チャプターを削除',
     'chapters_delete_named' => 'チャプター「:chapterName」を削除',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
+    'chapters_delete_explain' => 'これにより、チャプター「:chapterName」が削除されます。このチャプターに存在するページもすべて削除されます。',
     'chapters_delete_confirm' => 'チャプターを削除してよろしいですか?',
     'chapters_edit' => 'チャプターを編集',
     'chapters_edit_named' => 'チャプター「:chapterName」を編集',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'チャプターを移動',
     'chapters_move_named' => 'チャプター「:chapterName」を移動',
     'chapter_move_success' => 'チャプターを「:bookName」に移動しました',
+    'chapters_copy' => 'チャプターをコピー',
+    'chapters_copy_success' => 'チャプターが正常にコピーされました',
     'chapters_permissions' => 'チャプター権限',
     'chapters_empty' => 'まだチャプター内にページはありません。',
     'chapters_permissions_active' => 'チャプターの権限は有効です',
@@ -180,7 +188,7 @@ return [
     'pages_delete_confirm' => 'このページを削除してもよろしいですか?',
     'pages_delete_draft_confirm' => 'このページの下書きを削除してもよろしいですか?',
     'pages_editing_named' => 'ページ :pageName を編集',
-    'pages_edit_draft_options' => 'Draft Options',
+    'pages_edit_draft_options' => '下書きオプション',
     'pages_edit_save_draft' => '下書きを保存',
     'pages_edit_draft' => 'ページの下書きを編集',
     'pages_editing_draft' => '下書きを編集中',
@@ -198,25 +206,25 @@ return [
     'pages_md_preview' => 'プレビュー',
     'pages_md_insert_image' => '画像を挿入',
     'pages_md_insert_link' => 'エンティティへのリンクを挿入',
-    'pages_md_insert_drawing' => 'Insert Drawing',
+    'pages_md_insert_drawing' => '描画を追加',
     'pages_not_in_chapter' => 'チャプターが設定されていません',
     'pages_move' => 'ページを移動',
     'pages_move_success' => 'ページを ":parentName" へ移動しました',
-    'pages_copy' => 'Copy Page',
-    'pages_copy_desination' => 'Copy Destination',
-    'pages_copy_success' => 'Page successfully copied',
+    'pages_copy' => 'ページをコピー',
+    'pages_copy_desination' => 'コピー先',
+    'pages_copy_success' => 'ページが正常にコピーされました',
     'pages_permissions' => 'ページの権限設定',
     'pages_permissions_success' => 'ページの権限を更新しました',
-    'pages_revision' => 'Revision',
+    'pages_revision' => '編集履歴',
     'pages_revisions' => '編集履歴',
     'pages_revisions_named' => ':pageName のリビジョン',
     'pages_revision_named' => ':pageName のリビジョン',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => '#:id :summary から復元',
     'pages_revisions_created_by' => '作成者',
     'pages_revisions_date' => '日付',
-    'pages_revisions_number' => 'リビジョン',
-    'pages_revisions_numbered' => 'Revision #:id',
-    'pages_revisions_numbered_changes' => 'Revision #:id Changes',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'リビジョン #:id',
+    'pages_revisions_numbered_changes' => 'リビジョン #:id の変更',
     'pages_revisions_changelog' => '説明',
     'pages_revisions_changes' => '変更点',
     'pages_revisions_current' => '現在のバージョン',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => '新規ページ',
     'pages_editing_draft_notification' => ':timeDiffに保存された下書きを編集しています。',
     'pages_draft_edited_notification' => 'このページは更新されています。下書きを破棄することを推奨します。',
+    'pages_draft_page_changed_since_creation' => 'この下書きが作成されてから、このページが更新されました。この下書きを破棄するか、ページの変更を上書きしないように注意することを推奨します。',
     'pages_draft_edit_active' => [
         'start_a' => ':count人のユーザがページの編集を開始しました',
         'start_b' => ':userNameがページの編集を開始しました',
@@ -238,21 +247,31 @@ return [
         'message' => ':start :time. 他のユーザによる更新を上書きしないよう注意してください。',
     ],
     'pages_draft_discarded' => '下書きが破棄されました。エディタは現在の内容へ復元されています。',
-    'pages_specific' => 'Specific Page',
-    'pages_is_template' => 'Page Template',
+    'pages_specific' => '特定のページ',
+    'pages_is_template' => 'ページテンプレート',
 
     // Editor Sidebar
     'page_tags' => 'タグ',
-    'chapter_tags' => 'Chapter Tags',
-    'book_tags' => 'Book Tags',
-    'shelf_tags' => 'Shelf Tags',
+    'chapter_tags' => 'チャプターのタグ',
+    'book_tags' => 'ブックのタグ',
+    'shelf_tags' => '本棚のタグ',
     'tag' => 'タグ',
-    'tags' =>  'Tags',
-    'tag_name' =>  'Tag Name',
+    'tags' =>  'タグ',
+    'tag_name' =>  'タグの名前',
     'tag_value' => '内容 (オプション)',
     'tags_explain' => "タグを設定すると、コンテンツの管理が容易になります。\nより高度な管理をしたい場合、タグに内容を設定できます。",
     'tags_add' => 'タグを追加',
-    'tags_remove' => 'Remove this tag',
+    'tags_remove' => 'このタグを削除',
+    'tags_usages' => 'タグの総使用回数',
+    'tags_assigned_pages' => '割り当てられているページの数',
+    'tags_assigned_chapters' => '割り当てられているチャプターの数',
+    'tags_assigned_books' => '割り当てられているブックの数',
+    'tags_assigned_shelves' => '割り当てられている本棚の数',
+    'tags_x_unique_values' => ':count個のユニークな値',
+    'tags_all_values' => '全ての値',
+    'tags_view_tags' => 'タグを表示',
+    'tags_view_existing_tags' => '既存のタグを表示',
+    'tags_list_empty_hint' => 'タグはページエディタのサイドバーまたはブック、チャプター、本棚の詳細を編集しているときに割り当てることができます。',
     'attachments' => '添付ファイル',
     'attachments_explain' => 'ファイルをアップロードまたはリンクを添付することができます。これらはサイドバーで確認できます。',
     'attachments_explain_instant_save' => 'この変更は即座に保存されます。',
@@ -260,7 +279,7 @@ return [
     'attachments_upload' => 'アップロード',
     'attachments_link' => 'リンクを添付',
     'attachments_set_link' => 'リンクを設定',
-    'attachments_delete' => 'Are you sure you want to delete this attachment?',
+    'attachments_delete' => 'この添付ファイルを削除してよろしいですか?',
     'attachments_dropzone' => 'ファイルをドロップするか、クリックして選択',
     'attachments_no_files' => 'ファイルはアップロードされていません',
     'attachments_explain_link' => 'ファイルをアップロードしたくない場合、他のページやクラウド上のファイルへのリンクを添付できます。',
@@ -269,7 +288,7 @@ return [
     'attachments_link_url' => 'ファイルURL',
     'attachments_link_url_hint' => 'WebサイトまたはファイルへのURL',
     'attach' => '添付',
-    'attachments_insert_link' => 'Add Attachment Link to Page',
+    'attachments_insert_link' => '添付ファイルへのリンクをページに追加',
     'attachments_edit_file' => 'ファイルを編集',
     'attachments_edit_file_name' => 'ファイル名',
     'attachments_edit_drop_upload' => 'ファイルをドロップするか、クリックしてアップロード',
@@ -279,12 +298,12 @@ return [
     'attachments_file_uploaded' => 'ファイルがアップロードされました',
     'attachments_file_updated' => 'ファイルが更新されました',
     'attachments_link_attached' => 'リンクがページへ添付されました',
-    'templates' => 'Templates',
-    'templates_set_as_template' => 'Page is a template',
-    'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
-    'templates_replace_content' => 'Replace page content',
-    'templates_append_content' => 'Append to page content',
-    'templates_prepend_content' => 'Prepend to page content',
+    'templates' => 'テンプレート',
+    'templates_set_as_template' => 'テンプレートに設定',
+    'templates_explain_set_as_template' => 'このページをテンプレートとして設定すると、他のページを作成する際にこの内容を利用することができます。他のユーザーは、このページの表示権限を持っていればこのテンプレートを使用できます。',
+    'templates_replace_content' => 'ページの内容を置換',
+    'templates_append_content' => 'ページの末尾に追加',
+    'templates_prepend_content' => 'ページの先頭に追加',
 
     // Profile View
     'profile_user_for_x' => ':time前に作成',
@@ -292,7 +311,7 @@ return [
     'profile_not_created_pages' => ':userNameはページを作成していません',
     'profile_not_created_chapters' => ':userNameはチャプターを作成していません',
     'profile_not_created_books' => ':userNameはブックを作成していません',
-    'profile_not_created_shelves' => ':userName has not created any shelves',
+    'profile_not_created_shelves' => ':userNameは本棚を作成していません',
 
     // Comments
     'comment' => 'コメント',
@@ -314,7 +333,15 @@ return [
 
     // Revision
     'revision_delete_confirm' => 'このリビジョンを削除しますか?',
-    'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
+    'revision_restore_confirm' => 'このリビジョンを復元してよろしいですか?現在のページの内容が置換されます。',
     'revision_delete_success' => 'リビジョンを削除しました',
-    'revision_cannot_delete_latest' => '最新のリビジョンを削除できません。'
+    'revision_cannot_delete_latest' => '最新のリビジョンを削除できません。',
+
+    // Copy view
+    'copy_consider' => 'コンテンツをコピーする場合は以下の点にご注意ください。',
+    'copy_consider_permissions' => 'カスタム権限設定はコピーされません。',
+    'copy_consider_owner' => 'あなたはコピーされた全てのコンテンツの所有者になります。',
+    'copy_consider_images' => 'ページの画像ファイルは複製されず、元の画像は最初にアップロードされたページとの関係を保持します。',
+    'copy_consider_attachments' => 'ページの添付ファイルはコピーされません。',
+    'copy_consider_access' => '場所、所有者または権限を変更すると、以前アクセスできなかったユーザーがこのコンテンツにアクセスできるようになる可能性があります。',
 ];
index 983e07a3a524aed553fb875fa070aed5a466b0d1..d63e9bf365b8bba17ddbbb674e571dc48b2f8b12 100644 (file)
@@ -13,18 +13,22 @@ return [
     'email_already_confirmed' => 'Eメールは既に確認済みです。ログインしてください。',
     'email_confirmation_invalid' => 'この確認トークンは無効か、または既に使用済みです。登録を再試行してください。',
     'email_confirmation_expired' => '確認トークンは有効期限切れです。確認メールを再送しました。',
-    'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed',
+    'email_confirmation_awaiting' => '使用中のアカウントのメールアドレスを確認する必要があります',
     'ldap_fail_anonymous' => '匿名バインドを用いたLDAPアクセスに失敗しました',
     'ldap_fail_authed' => '識別名, パスワードを用いたLDAPアクセスに失敗しました',
     'ldap_extension_not_installed' => 'LDAP PHP extensionがインストールされていません',
     'ldap_cannot_connect' => 'LDAPサーバに接続できませんでした',
     'saml_already_logged_in' => '既にログインしています',
-    'saml_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
-    'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
-    'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
-    'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
+    'saml_user_not_registered' => 'ユーザー :name は登録されておらず、自動登録は無効になっています',
+    'saml_no_email_address' => '外部認証システムから提供されたデータに、このユーザーのメールアドレスが見つかりませんでした',
+    'saml_invalid_response_id' => '外部認証システムからの要求がアプリケーションによって開始されたプロセスによって認識されません。ログイン後に戻るとこの問題が発生する可能性があります。',
+    'saml_fail_authed' => ':systemを利用したログインに失敗しました。システムは正常な認証を提供しませんでした。',
+    'oidc_already_logged_in' => '既にログインしています',
+    'oidc_user_not_registered' => 'ユーザー :name は登録されておらず、自動登録は無効になっています',
+    'oidc_no_email_address' => '外部認証システムから提供されたデータに、このユーザーのメールアドレスが見つかりませんでした',
+    'oidc_fail_authed' => ':systemを利用したログインに失敗しました。システムは正常な認証を提供しませんでした。',
     'social_no_action_defined' => 'アクションが定義されていません',
-    'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
+    'social_login_bad_response' => ":socialAccountのログイン中にエラーが発生しました:\n:error",
     'social_account_in_use' => ':socialAccountアカウントは既に使用されています。:socialAccountのオプションからログインを試行してください。',
     'social_account_email_in_use' => ':emailは既に使用されています。ログイン後、プロフィール設定から:socialAccountアカウントを接続できます。',
     'social_account_existing' => 'アカウント:socialAccountは既にあなたのプロフィールに接続されています。',
@@ -33,16 +37,16 @@ return [
     'social_account_register_instructions' => 'まだアカウントをお持ちでない場合、:socialAccountオプションから登録できます。',
     'social_driver_not_found' => 'Social driverが見つかりません。',
     'social_driver_not_configured' => 'あなたの:socialAccount設定は正しく構成されていません。',
-    'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
+    'invite_token_expired' => 'この招待リンクの有効期限が切れています。 代わりにアカウントのパスワードをリセットしてみてください。',
 
     // System
     'path_not_writable' => 'ファイルパス :filePath へアップロードできませんでした。サーバ上での書き込みが許可されているか確認してください。',
     'cannot_get_image_from_url' => ':url から画像を取得できませんでした。',
     'cannot_create_thumbs' => 'このサーバはサムネイルを作成できません。GD PHP extensionがインストールされていることを確認してください。',
     'server_upload_limit' => 'このサイズの画像をアップロードすることは許可されていません。ファイルサイズを小さくし、再試行してください。',
-    'uploaded'  => 'The server does not allow uploads of this size. Please try a smaller file size.',
+    'uploaded'  => 'このサイズの画像をアップロードすることは許可されていません。ファイルサイズを小さくし、再試行してください。',
     'image_upload_error' => '画像アップロード時にエラーが発生しました。',
-    'image_upload_type_error' => 'The image type being uploaded is invalid',
+    'image_upload_type_error' => 'アップロード中の画像の種類が無効です',
     'file_upload_timeout' => 'ファイルのアップロードがタイムアウトしました。',
 
     // Attachments
@@ -50,11 +54,11 @@ return [
 
     // Pages
     'page_draft_autosave_fail' => '下書きの保存に失敗しました。インターネットへ接続してください。',
-    'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
+    'page_custom_home_deletion' => 'ホームページに設定されているページは削除できません',
 
     // Entities
     'entity_not_found' => 'エンティティが見つかりません',
-    'bookshelf_not_found' => 'Bookshelf not found',
+    'bookshelf_not_found' => '本棚が見つかりません',
     'book_not_found' => 'ブックが見つかりません',
     'page_not_found' => 'ページが見つかりません',
     'chapter_not_found' => 'チャプターが見つかりません',
@@ -70,33 +74,36 @@ return [
     'role_cannot_be_edited' => 'この役割は編集できません',
     'role_system_cannot_be_deleted' => 'この役割はシステムで管理されているため、削除できません',
     'role_registration_default_cannot_delete' => 'この役割を登録時のデフォルトに設定することはできません',
-    'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.',
+    'role_cannot_remove_only_admin' => 'このユーザーは、管理者の役割に割り当てられている唯一のユーザーです。削除する前に別のユーザーに管理者の役割を割り当ててください。',
 
     // Comments
-    'comment_list' => 'An error occurred while fetching the comments.',
+    'comment_list' => 'コメントを取得中にエラーが発生しました。',
     'cannot_add_comment_to_draft' => '下書きにコメントは追加できません。',
-    'comment_add' => 'An error occurred while adding / updating the comment.',
-    'comment_delete' => 'An error occurred while deleting the comment.',
-    'empty_comment' => 'Cannot add an empty comment.',
+    'comment_add' => 'コメントの追加・更新中にエラーが発生しました。',
+    'comment_delete' => 'コメントを削除中にエラーが発生しました。',
+    'empty_comment' => '空のコメントは追加できません。',
 
     // Error pages
     '404_page_not_found' => 'ページが見つかりません',
     'sorry_page_not_found' => 'ページを見つけることができませんでした。',
-    'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
+    'sorry_page_not_found_permission_warning' => 'このページが存在すると思われる場合は、閲覧の権限がない可能性があります。',
+    'image_not_found' => '画像が見つかりません',
+    'image_not_found_subtitle' => '画像を見つけることができませんでした。',
+    'image_not_found_details' => 'この画像が存在することを予期していた場合は、削除された可能性があります。',
     'return_home' => 'ホームに戻る',
     'error_occurred' => 'エラーが発生しました',
     'app_down' => ':appNameは現在停止しています',
     'back_soon' => '回復までしばらくお待ちください。',
 
     // API errors
-    'api_no_authorization_found' => 'No authorization token found on the request',
-    'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect',
-    'api_user_token_not_found' => 'No matching API token was found for the provided authorization token',
-    'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect',
-    'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls',
+    'api_no_authorization_found' => 'リクエストに認証トークンが見つかりません',
+    'api_bad_authorization_format' => 'リクエストに認証トークンが見つかりましたが、形式が正しくないようです',
+    'api_user_token_not_found' => '提供された認証トークンに一致するAPIトークンが見つかりませんでした',
+    'api_incorrect_token_secret' => '利用されたAPIトークンに対して提供されたシークレットが正しくありません',
+    'api_user_no_api_permission' => '使用されているAPIトークンの所有者には、API呼び出しを行う権限がありません',
     'api_user_token_expired' => '認証トークンが期限切れです。',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'テストメール送信時にエラーが発生しました:',
 
 ];
index e92a35500a03601af42759f7d6b868a6dcff7d8b..cf4d43114fcc5dbb7fd1b12cdb74e1ce7a984ec4 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'パスワードは6文字以上である必要があります。',
     'user' => "このEメールアドレスに一致するユーザが見つかりませんでした。",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'このメールアドレスのパスワードリセットトークンは無効です。',
     'sent' => 'パスワードリセットリンクを送信しました。',
     'reset' => 'パスワードはリセットされました。',
 
index 91d19a8eb070bd05ec883e707deecb0473e0971b..11a052d3922336823512b4ae9e0375d3cd9c1d35 100644 (file)
@@ -29,19 +29,19 @@ return [
     'app_editor_desc' => 'ここで選択されたエディタを全ユーザが使用します。',
     'app_custom_html' => 'カスタムheadタグ',
     'app_custom_html_desc' => 'スタイルシートやアナリティクスコード追加したい場合、ここを編集します。これは<head>の最下部に挿入されます。',
-    'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
+    'app_custom_html_disabled_notice' => '重大な変更を元に戻せるよう、この設定ページではカスタムのHTML headコンテンツが無効になっています。',
     'app_logo' => 'ロゴ',
     'app_logo_desc' => '高さ43pxで表示されます。これを上回る場合、自動で縮小されます。',
     'app_primary_color' => 'プライマリカラー',
     'app_primary_color_desc' => '16進数カラーコードで入力します。空にした場合、デフォルトの色にリセットされます。',
-    'app_homepage' => 'Application Homepage',
-    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
+    'app_homepage' => 'アプリケーションのホームページ',
+    'app_homepage_desc' => 'デフォルトのビューの代わりにホームページに表示するビューを選択します。選択したページの権限は無視されます。',
     'app_homepage_select' => 'ページを選択',
     'app_footer_links' => 'フッタのリンク',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
+    'app_footer_links_desc' => 'サイトフッタ内に表示するリンクを追加します。これらはログインを必要としないページを含め、ほとんどのページの下部に表示されます。「trans::<key>」のラベルを使用して、システム定義の翻訳を使用できます。例えば「trans::common.privacy_policy」を使用すると翻訳されたテキスト「プライバシーポリシー」が提供され、「trans::common.terms_of_service」を使用すると翻訳されたテキスト「利用規約」が提供されます。',
     'app_footer_links_label' => '表示するテキスト',
     'app_footer_links_url' => 'リンク先の URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_footer_links_add' => 'フッタのリンクを追加',
     'app_disable_comments' => 'コメントを無効にする',
     'app_disable_comments_toggle' => 'コメントを無効にする',
     'app_disable_comments_desc' => 'アプリケーション内のすべてのページのコメントを無効にします。既存のコメントは表示されません。',
@@ -49,17 +49,17 @@ return [
     // Color settings
     'content_colors' => 'コンテンツの色',
     'content_colors_desc' => 'ページ構成階層のすべての要素に色を設定します。読みやすさを考慮して、デフォルトの色と同じような明るさの色を選ぶことをお勧めします。',
-    'bookshelf_color' => 'Shelf Color',
-    'book_color' => 'Book Color',
-    'chapter_color' => 'Chapter Color',
-    'page_color' => 'Page Color',
-    'page_draft_color' => 'Page Draft Color',
+    'bookshelf_color' => '本棚の色',
+    'book_color' => 'ブックの色',
+    'chapter_color' => 'チャプターの色',
+    'page_color' => 'ページの色',
+    'page_draft_color' => '下書きページの色',
 
     // Registration Settings
     'reg_settings' => '登録設定',
     'reg_enable' => '登録を有効にする',
     'reg_enable_toggle' => '登録を有効にする',
-    'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.',
+    'reg_enable_desc' => '登録を有効にすると、ユーザーはアプリケーションユーザーとしてサインアップできるようになります。登録するとデフォルトの役割が1つ与えられます。',
     'reg_default_role' => '新規登録時のデフォルト役割',
     'reg_enable_external_warning' => '外部のLDAPまたはSAML認証が有効の場合、上記のオプションは無視されます。存在しないメンバーのユーザーアカウントは、使用している外部システムでの認証に成功した場合に自動的に作成されます。',
     'reg_email_confirmation' => '確認メール',
@@ -71,55 +71,58 @@ return [
 
     // Maintenance settings
     'maint' => 'メンテナンス',
-    'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "ページや履歴の内容をスキャンして、どの画像や図面が現在使用されているか、どの画像が余っているかをチェックします。この機能を実行する前に、データベースと画像の完全なバックアップを作成してください。",
+    'maint_image_cleanup' => '画像のクリーンアップ',
+    'maint_image_cleanup_desc' => 'ページや履歴の内容をスキャンして、どの画像や図面が現在使用されているか、どの画像が余っているかをチェックします。この機能を実行する前に、データベースと画像の完全なバックアップを作成してください。',
     'maint_delete_images_only_in_revisions' => 'また、古いページのリビジョンにしか存在しない画像も削除します。',
     'maint_image_cleanup_run' => 'クリーンアップを実行',
     'maint_image_cleanup_warning' => ':count 個、使用されていない可能性のある画像が見つかりました。これらの画像を削除してもよろしいですか?',
-    'maint_image_cleanup_success' => ':count potentially unused images found and deleted!',
-    'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!',
+    'maint_image_cleanup_success' => '使われていない可能性のある画像を:count個発見し、削除しました。',
+    'maint_image_cleanup_nothing_found' => '未使用の画像がないため、何も削除しませんでした。',
     'maint_send_test_email' => 'テストメールを送信',
-    'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.',
-    'maint_send_test_email_run' => 'Send test email',
-    'maint_send_test_email_success' => 'Email sent to :address',
+    'maint_send_test_email_desc' => 'プロフィールに指定されたメールアドレスにテストメールを送信します。',
+    'maint_send_test_email_run' => 'テストメールを送信',
+    'maint_send_test_email_success' => ':addressにメールを送信しました',
     'maint_send_test_email_mail_subject' => 'テストメール',
-    'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
-    'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
-    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
-    'maint_recycle_bin_open' => 'Open Recycle Bin',
+    'maint_send_test_email_mail_greeting' => 'メール配信は正常に動作しているようです!',
+    'maint_send_test_email_mail_text' => 'おめでとうございます!この通知メールが届いたということは、あなたのメール設定は適切であると思われます。',
+    'maint_recycle_bin_desc' => '削除された本棚・ブック・チャプター・ページはごみ箱に送られるため、復元したり完全に削除したりできます。システムの設定によっては、ごみ箱の古いアイテムがしばらくすると自動的に削除される場合があります。',
+    'maint_recycle_bin_open' => 'ごみ箱を開く',
 
     // Recycle Bin
-    'recycle_bin' => 'Recycle Bin',
-    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
-    'recycle_bin_deleted_by' => 'Deleted By',
-    'recycle_bin_deleted_at' => 'Deletion Time',
-    'recycle_bin_permanently_delete' => 'Permanently Delete',
-    'recycle_bin_restore' => 'Restore',
-    'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
-    'recycle_bin_empty' => 'Empty Recycle Bin',
-    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
-    'recycle_bin_restore_list' => 'Items to be Restored',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
+    'recycle_bin' => 'ごみ箱',
+    'recycle_bin_desc' => '削除されたアイテムを復元するか、システムから完全に削除できます。このリストは、権限フィルターが適用されているシステム内の同様のアクティビティリストとは異なり、フィルタリングされていません。',
+    'recycle_bin_deleted_item' => '削除されたアイテム',
+    'recycle_bin_deleted_parent' => '親',
+    'recycle_bin_deleted_by' => '削除した人',
+    'recycle_bin_deleted_at' => '削除日時',
+    'recycle_bin_permanently_delete' => '完全に削除',
+    'recycle_bin_restore' => '復元',
+    'recycle_bin_contents_empty' => 'ごみ箱は現在空です',
+    'recycle_bin_empty' => 'ごみ箱を空にする',
+    'recycle_bin_empty_confirm' => 'ごみ箱のすべてのアイテムが、各アイテムに含まれるコンテンツも含めて完全に削除されます。本当にごみ箱を空にしますか?',
+    'recycle_bin_destroy_confirm' => 'この操作により、このアイテムと以下にリストされている子要素がシステムから完全に削除され、このコンテンツを復元できなくなります。このアイテムを完全に削除してもよろしいですか?',
+    'recycle_bin_destroy_list' => '削除されるアイテム',
+    'recycle_bin_restore_list' => '復元されるアイテム',
+    'recycle_bin_restore_confirm' => 'この操作により、すべての子要素を含む削除されたアイテムが元の場所に復元されます。元の場所が削除されてごみ箱に入っている場合は、親アイテムも復元する必要があります。',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin_restore_parent' => '親を復元',
+    'recycle_bin_destroy_notification' => 'ごみ箱から合計:count個のアイテムを削除しました。',
+    'recycle_bin_restore_notification' => 'ごみ箱から合計:count個のアイテムを復元しました。',
 
     // Audit Log
-    'audit' => 'Audit Log',
-    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'audit_event_filter' => 'Event Filter',
-    'audit_event_filter_no_filter' => 'No Filter',
-    'audit_deleted_item' => 'Deleted Item',
-    'audit_deleted_item_name' => 'Name: :name',
-    'audit_table_user' => 'User',
-    'audit_table_event' => 'Event',
-    'audit_table_related' => 'Related Item or Detail',
-    'audit_table_date' => 'Activity Date',
-    'audit_date_from' => 'Date Range From',
-    'audit_date_to' => 'Date Range To',
+    'audit' => '監査ログ',
+    'audit_desc' => 'この監査ログには、システムで追跡されているアクティビティのリストが表示されます。このリストは、権限フィルターが適用されているシステム内の同様のアクティビティリストとは異なり、フィルタリングされていません。',
+    'audit_event_filter' => 'イベントフィルター',
+    'audit_event_filter_no_filter' => 'フィルターなし',
+    'audit_deleted_item' => '削除されたアイテム',
+    'audit_deleted_item_name' => '名前: :name',
+    'audit_table_user' => 'ユーザー',
+    'audit_table_event' => 'イベント',
+    'audit_table_related' => '関連アイテムまたは詳細',
+    'audit_table_ip' => 'IPアドレス',
+    'audit_table_date' => 'アクティビティの日時',
+    'audit_date_from' => '開始日',
+    'audit_date_to' => '終了日',
 
     // Role Settings
     'roles' => '役割',
@@ -136,93 +139,127 @@ return [
     'role_details' => '概要',
     'role_name' => '役割名',
     'role_desc' => '役割の説明',
-    'role_external_auth_id' => 'External Authentication IDs',
+    'role_mfa_enforced' => '多要素認証を要求する',
+    'role_external_auth_id' => '外部認証ID',
     'role_system' => 'システム権限',
     'role_manage_users' => 'ユーザ管理',
     'role_manage_roles' => '役割と権限の管理',
     'role_manage_entity_permissions' => '全てのブック, チャプター, ページに対する権限の管理',
     'role_manage_own_entity_permissions' => '自身のブック, チャプター, ページに対する権限の管理',
-    'role_manage_page_templates' => 'Manage page templates',
-    'role_access_api' => 'Access system API',
+    'role_manage_page_templates' => 'ページテンプレートの管理',
+    'role_access_api' => 'システムのAPIへのアクセス',
     'role_manage_settings' => 'アプリケーション設定の管理',
+    'role_export_content' => 'コンテンツのエクスポート',
     'role_asset' => 'アセット権限',
-    '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.',
+    'roles_system_warning' => '上記の3つの権限のいずれかを付与することは、ユーザーが自分の特権またはシステム内の他のユーザーの特権を変更できる可能性があることに注意してください。これらの権限は信頼できるユーザーにのみ割り当ててください。',
     'role_asset_desc' => '各アセットに対するデフォルトの権限を設定します。ここで設定した権限が優先されます。',
-    'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
+    'role_asset_admins' => '管理者にはすべてのコンテンツへのアクセス権が自動的に付与されますが、これらのオプションはUIオプションを表示または非表示にする場合があります。',
     'role_all' => '全て',
     'role_own' => '自身',
     'role_controlled_by_asset' => 'このアセットに対し、右記の操作を許可:',
     'role_save' => '役割を保存',
     'role_update_success' => '役割を更新しました',
-    'role_users' => 'この役割を持つユーザ',
-    'role_users_none' => 'ã\81\93ã\81®å½¹å\89²ã\81\8cä»\98ä¸\8eã\81\95ã\82\8cã\81\9fã\83¦ã\83¼ã\82¶ã\81¯å±\85ません',
+    'role_users' => 'この役割を持つユーザ',
+    'role_users_none' => 'ã\81\93ã\81®å½¹å\89²ã\81\8cä»\98ä¸\8eã\81\95ã\82\8cã\81\9fã\83¦ã\83¼ã\82¶ã\83¼ã\81¯ã\81\84ません',
 
     // Users
-    'users' => 'ユーザ',
+    'users' => 'ユーザ',
     'user_profile' => 'ユーザプロフィール',
-    'users_add_new' => 'ユーザを追加',
-    'users_search' => 'ユーザ検索',
-    'users_latest_activity' => 'Latest Activity',
-    'users_details' => 'User Details',
-    'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
-    'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
-    'users_role' => 'ユーザ役割',
-    'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
+    'users_add_new' => 'ã\83¦ã\83¼ã\82¶ã\83¼ã\82\92追å\8a ',
+    'users_search' => 'ユーザ検索',
+    'users_latest_activity' => '最新のアクティビティ',
+    'users_details' => 'ユーザーの詳細',
+    'users_details_desc' => 'このユーザーの表示名とメールアドレスを設定します。メールアドレスは、アプリケーションへのログインに使用されます。',
+    'users_details_desc_no_email' => 'このユーザーの表示名を設定して、他のユーザーが認識できるようにします。',
+    'users_role' => 'ユーザーの役割',
+    'users_role_desc' => 'このユーザーに割り当てる役割を選択します。ユーザーが複数の役割に割り当てられている場合は、それらの役割の権限が重ね合わされ、割り当てられた役割のすべての権限が与えられます。',
     'users_password' => 'ユーザー パスワード',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
-    'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
-    'users_send_invite_option' => 'Send user invite email',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
+    'users_send_invite_text' => 'このユーザーに招待メールを送信してユーザー自身にパスワードを設定してもらうか、あなたがここでパスワードを設定するかを選択できます。',
+    'users_send_invite_option' => 'ユーザーに招待メールを送信',
     'users_external_auth_id' => '外部認証ID',
-    'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
+    'users_external_auth_id_desc' => 'これは外部認証システムと通信するときにこのユーザーの照合に使用されるIDです。',
     'users_password_warning' => 'パスワードを変更したい場合のみ入力してください',
-    'users_system_public' => 'このユーザはアプリケーションにアクセスする全てのゲストを表します。ログインはできませんが、自動的に割り当てられます。',
+    'users_system_public' => 'ã\81\93ã\81®ã\83¦ã\83¼ã\82¶ã\83¼ã\81¯ã\82¢ã\83\97ã\83ªã\82±ã\83¼ã\82·ã\83§ã\83³ã\81«ã\82¢ã\82¯ã\82»ã\82¹ã\81\99ã\82\8bå\85¨ã\81¦ã\81®ã\82²ã\82¹ã\83\88ã\82\92表ã\81\97ã\81¾ã\81\99ã\80\82ã\83­ã\82°ã\82¤ã\83³ã\81¯ã\81§ã\81\8dã\81¾ã\81\9bã\82\93ã\81\8cã\80\81è\87ªå\8b\95ç\9a\84ã\81«å\89²ã\82\8aå½\93ã\81¦ã\82\89ã\82\8cã\81¾ã\81\99ã\80\82',
     'users_delete' => 'ユーザを削除',
     'users_delete_named' => 'ユーザ「:userName」を削除',
     'users_delete_warning' => 'ユーザ「:userName」を完全に削除します。',
     'users_delete_confirm' => '本当にこのユーザを削除してよろしいですか?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
-    'users_none_selected' => 'No user selected',
-    'users_delete_success' => 'User successfully removed',
-    'users_edit' => 'ユーザ編集',
+    'users_migrate_ownership' => '所有権を移行',
+    'users_migrate_ownership_desc' => '別のユーザーをこのユーザーが現在所有しているすべてのアイテムの所有者にする場合は、ここでユーザーを選択します。',
+    'users_none_selected' => 'ユーザが選択されていません',
+    'users_delete_success' => 'ユーザーを正常に削除しました',
+    'users_edit' => 'ユーザ編集',
     'users_edit_profile' => 'プロフィール編集',
     'users_edit_success' => 'ユーザを更新しました',
     'users_avatar' => 'アバター',
     'users_avatar_desc' => '256pxの正方形である必要があります。',
     'users_preferred_language' => '使用言語',
-    'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.',
+    'users_preferred_language_desc' => 'このオプションは、アプリケーションのユーザーインターフェイスに使用される言語を変更します。これは、ユーザーが作成したコンテンツには影響しません。',
     'users_social_accounts' => 'ソーシャルアカウント',
     'users_social_accounts_info' => 'アカウントを接続すると、ログインが簡単になります。ここでアカウントの接続を解除すると、そのアカウントを経由したログインを禁止できます。接続解除後、各ソーシャルアカウントの設定にてこのアプリケーションへのアクセス許可を解除してください。',
     'users_social_connect' => 'アカウントを接続',
     'users_social_disconnect' => 'アカウントを接続解除',
     'users_social_connected' => '「:socialAccount」がプロフィールに接続されました。',
     'users_social_disconnected' => '「:socialAccount」がプロフィールから接続解除されました。',
-    'users_api_tokens' => 'API Tokens',
-    'users_api_tokens_none' => 'No API tokens have been created for this user',
-    'users_api_tokens_create' => 'Create Token',
-    'users_api_tokens_expires' => 'Expires',
-    'users_api_tokens_docs' => 'API Documentation',
+    'users_api_tokens' => 'APIトークン',
+    'users_api_tokens_none' => 'このユーザーにはAPIトークンが作成されていません',
+    'users_api_tokens_create' => 'トークンを作成',
+    'users_api_tokens_expires' => '有効期限',
+    'users_api_tokens_docs' => 'APIドキュメント',
+    'users_mfa' => '多要素認証',
+    'users_mfa_desc' => 'アカウントのセキュリティを強化するために、多要素認証を設定してください。',
+    'users_mfa_x_methods' => ':count個の手段が設定されています|:count個の手段が設定されています',
+    'users_mfa_configure' => '手段を設定',
 
     // API Tokens
-    'user_api_token_create' => 'Create API Token',
-    'user_api_token_name' => 'Name',
-    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
-    'user_api_token_expiry' => 'Expiry Date',
-    'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
-    'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
-    'user_api_token_create_success' => 'API token successfully created',
-    'user_api_token_update_success' => 'API token successfully updated',
-    'user_api_token' => 'API Token',
-    'user_api_token_id' => 'Token ID',
-    'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
-    'user_api_token_secret' => 'Token Secret',
-    'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.',
-    'user_api_token_created' => 'Token created :timeAgo',
-    'user_api_token_updated' => 'Token updated :timeAgo',
-    'user_api_token_delete' => 'Delete Token',
-    'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
-    'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
-    'user_api_token_delete_success' => 'API token successfully deleted',
+    'user_api_token_create' => 'APIトークンの作成',
+    'user_api_token_name' => '名前',
+    'user_api_token_name_desc' => '利用目的を忘れないよう、トークンに読みやすい名前を付けます。',
+    'user_api_token_expiry' => '有効期限',
+    'user_api_token_expiry_desc' => 'このトークンの有効期限が切れる日付を設定します。この日付を過ぎると、このトークンを使用したリクエストは機能しなくなります。このフィールドを空白のままにすると、100年先に有効期限が設定されます。',
+    'user_api_token_create_secret_message' => 'このトークンを作成するとすぐに、「トークンID」と「トークンシークレット」が生成されて表示されます。シークレットは一度しか表示されないため、続行する前に必ず値を安全な場所にコピーしてください。',
+    'user_api_token_create_success' => 'APIトークンが正常に作成されました',
+    'user_api_token_update_success' => 'APIトークンが正常に更新されました',
+    'user_api_token' => 'APIトークン',
+    'user_api_token_id' => 'トークンID',
+    'user_api_token_id_desc' => 'これは、システムが生成した編集不可能なトークンの識別子で、APIリクエストで提供する必要があります。',
+    'user_api_token_secret' => 'トークンシークレット',
+    'user_api_token_secret_desc' => 'これは、システムで生成されたトークンシークレットであり、APIリクエストで提供する必要があります。これは一度しか表示されないので、この値を安全な場所にコピーしてください。',
+    'user_api_token_created' => 'トークンの作成: :timeAgo',
+    'user_api_token_updated' => 'トークンの更新: :timeAgo',
+    'user_api_token_delete' => 'トークンを削除',
+    'user_api_token_delete_warning' => 'これにより、このAPIトークン「:tokenName」がシステムから完全に削除されます。',
+    'user_api_token_delete_confirm' => 'このAPIトークンを削除してもよろしいですか?',
+    'user_api_token_delete_success' => 'APIトークンが正常に削除されました',
+
+    // Webhooks
+    'webhooks' => 'Webhook',
+    'webhooks_create' => 'Webhookを作成',
+    'webhooks_none_created' => 'Webhookはまだ作成されていません。',
+    'webhooks_edit' => 'Webhookを編集',
+    'webhooks_save' => 'Webhookを保存',
+    'webhooks_details' => 'Webhookの詳細',
+    'webhooks_details_desc' => 'ユーザーフレンドリーな名前とWebhookデータの送信先にするPOSTエンドポイントを指定します。',
+    'webhooks_events' => 'Webhookのイベント',
+    'webhooks_events_desc' => 'このWebhookの呼び出しをトリガーするすべてのイベントを選択します。',
+    'webhooks_events_warning' => 'これらのイベントはカスタム権限が適用されている場合でも、選択したすべてのイベントに対してトリガーされることに注意してください。このWebhookの利用により機密コンテンツが公開されないことを確認してください。',
+    'webhooks_events_all' => '全てのシステムイベント',
+    'webhooks_name' => 'Webhook名',
+    'webhooks_timeout' => 'Webhookリクエストタイムアウト (秒)',
+    'webhooks_endpoint' => 'Webhookエンドポイント',
+    'webhooks_active' => '有効なWebhook',
+    'webhook_events_table_header' => 'イベント',
+    'webhooks_delete' => 'Webhookを削除',
+    'webhooks_delete_warning' => 'これにより、このWebhook「:webhookName」がシステムから完全に削除されます。',
+    'webhooks_delete_confirm' => 'このWebhookを削除してよろしいですか?',
+    'webhooks_format_example' => 'Webhookのフォーマット例',
+    'webhooks_format_example_desc' => 'Webhookのデータは、設定されたエンドポイントにPOSTリクエストにより以下のフォーマットのJSONで送信されます。related_item と url プロパティはオプションであり、トリガーされるイベントの種類によって異なります。',
+    'webhooks_status' => 'Webhookの状態',
+    'webhooks_last_called' => '最後の実行:',
+    'webhooks_last_errored' => '最後のエラー:',
+    'webhooks_last_error_message' => '最後のエラーのメッセージ:',
+
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 7d9987ce073e22a0137fdef9a216cf9f4cc84d5c..f8a7f1326fe6361a6f9b9402091b6db9e3bc439c 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attributeは文字, 数値, ハイフンのみが含められます。',
     'alpha_num'            => ':attributeは文字と数値のみが含められます。',
     'array'                => ':attributeは配列である必要があります。',
+    'backup_codes'         => '提供されたコードは無効か、またはすでに使用されています。',
     'before'               => ':attributeは:date以前である必要があります。',
     'between'              => [
         'numeric' => ':attributeは:min〜:maxである必要があります。',
@@ -30,40 +31,40 @@ return [
     'digits'               => ':attributeは:digitsデジットである必要があります',
     'digits_between'       => ':attributeは:min〜:maxである必要があります。',
     'email'                => ':attributeは正しいEメールアドレスである必要があります。',
-    'ends_with' => 'The :attribute must end with one of the following: :values',
+    'ends_with' => ':attributeは:valuesのいずれかで終わる必要があります。',
     'filled'               => ':attributeは必須です。',
     'gt'                   => [
-        'numeric' => 'The :attribute must be greater than :value.',
-        'file'    => 'The :attribute must be greater than :value kilobytes.',
-        'string'  => 'The :attribute must be greater than :value characters.',
-        'array'   => 'The :attribute must have more than :value items.',
+        'numeric' => ':attributeは:valueより大きな値である必要があります。',
+        'file'    => ':attributeは:valueキロバイトより大きなファイルである必要があります。',
+        'string'  => ':attributeは:value文字より長い必要があります。',
+        'array'   => ':attributeには:value個より多くのアイテムを指定する必要があります。',
     ],
     'gte'                  => [
-        'numeric' => 'The :attribute must be greater than or equal :value.',
-        'file'    => 'The :attribute must be greater than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be greater than or equal :value characters.',
-        'array'   => 'The :attribute must have :value items or more.',
+        'numeric' => ':attributeは:value以上の値である必要があります。',
+        'file'    => ':attributeは:valueキロバイト以上のファイルである必要があります。',
+        'string'  => ':attributeは:value文字以上である必要があります。',
+        'array'   => ':attributeには:value個以上のアイテムを指定する必要があります。',
     ],
     'exists'               => '選択された:attributeは不正です。',
     'image'                => ':attributeは画像である必要があります。',
-    'image_extension'      => 'The :attribute must have a valid & supported image extension.',
+    'image_extension'      => ':attributeは有効かつサポートされている拡張子の画像である必要があります。',
     'in'                   => '選択された:attributeは不正です。',
     'integer'              => ':attributeは数値である必要があります。',
     'ip'                   => ':attributeは正しいIPアドレスである必要があります。',
-    'ipv4'                 => 'The :attribute must be a valid IPv4 address.',
-    'ipv6'                 => 'The :attribute must be a valid IPv6 address.',
-    'json'                 => 'The :attribute must be a valid JSON string.',
+    'ipv4'                 => ':attributeは有効なIPv4アドレスである必要があります。',
+    'ipv6'                 => ':attributeは有効なIPv6アドレスである必要があります。',
+    'json'                 => ':attributeは有効なJSON文字列である必要があります。',
     'lt'                   => [
-        'numeric' => 'The :attribute must be less than :value.',
-        'file'    => 'The :attribute must be less than :value kilobytes.',
-        'string'  => 'The :attribute must be less than :value characters.',
-        'array'   => 'The :attribute must have less than :value items.',
+        'numeric' => ':attributeは:valueより小さな値である必要があります。',
+        'file'    => ':attributeは:valueキロバイトより小さなファイルである必要があります。',
+        'string'  => ':attributeは:value文字より短い必要があります。',
+        'array'   => ':attributeには:value個より少ないアイテムを指定する必要があります。',
     ],
     'lte'                  => [
-        'numeric' => 'The :attribute must be less than or equal :value.',
-        'file'    => 'The :attribute must be less than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be less than or equal :value characters.',
-        'array'   => 'The :attribute must not have more than :value items.',
+        'numeric' => ':attributeは:value以下の値である必要があります。',
+        'file'    => ':attributeは:valueキロバイト以下のファイルである必要があります。',
+        'string'  => ':attributeは:value文字以下である必要があります。',
+        'array'   => ':attributeには:value個以下のアイテムを指定する必要があります。',
     ],
     'max'                  => [
         'numeric' => ':attributeは:maxを越えることができません。',
@@ -79,7 +80,7 @@ return [
         'array'   => ':attributeは:min個以上である必要があります。',
     ],
     'not_in'               => '選択された:attributeは不正です。',
-    'not_regex'            => 'The :attribute format is invalid.',
+    'not_regex'            => ':attributeの形式は不正です。',
     'numeric'              => ':attributeは数値である必要があります。',
     'regex'                => ':attributeのフォーマットは不正です。',
     'required'             => ':attributeは必須です。',
@@ -89,7 +90,7 @@ return [
     'required_without'     => ':valuesが設定されていない場合、:attributeは必須です。',
     'required_without_all' => ':valuesが設定されていない場合、:attributeは必須です。',
     'same'                 => ':attributeと:otherは一致している必要があります。',
-    'safe_url'             => 'The provided link may not be safe.',
+    'safe_url'             => '提供されたリンクは安全ではない可能性があります。',
     'size'                 => [
         'numeric' => ':attributeは:sizeである必要があります。',
         'file'    => ':attributeは:sizeキロバイトである必要があります。',
@@ -98,9 +99,10 @@ return [
     ],
     'string'               => ':attributeは文字列である必要があります。',
     'timezone'             => ':attributeは正しいタイムゾーンである必要があります。',
+    'totp'                 => '提供されたコードが無効または期限切れです。',
     'unique'               => ':attributeは既に使用されています。',
     'url'                  => ':attributeのフォーマットは不正です。',
-    'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
+    'uploaded'             => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。',
 
     // Custom validation lines
     'custom' => [
index fda7e4ef35ebb21aeca7c763841254356f5c75c5..1a68dd9a69a264c407e8609c53a0ade6443d2ec5 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => '문서 만들기',
-    'page_create_notification'    => '문서 만듦',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => '문서 수정',
-    'page_update_notification'    => '문서 수정함',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => '삭제 된 페이지',
-    'page_delete_notification'    => '문서 지움',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => '문서 복원',
-    'page_restore_notification'   => '문서 복원함',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => '문서 이동됨',
 
     // Chapters
     'chapter_create'              => '챕터 만들기',
-    'chapter_create_notification' => '챕터 만듦',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => '챕터 바꾸기',
-    'chapter_update_notification' => '챕터 바꿈',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => '삭제된 챕터',
-    'chapter_delete_notification' => '챕터 지움',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => '챕터 이동된',
 
     // Books
     'book_create'                 => '책자 만들기',
-    'book_create_notification'    => '책자 만듦',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => '책자 바꾸기',
-    'book_update_notification'    => '책자 바꿈',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => '삭제 된 책자',
-    'book_delete_notification'    => '책자 지움',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => '책자 정렬',
-    'book_sort_notification'      => '책자 정렬함',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => '서가 만들기',
-    'bookshelf_create_notification'    => '서가 만듦',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => '서가 바꾸기',
-    'bookshelf_update_notification'    => '서가 바꿈',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => '삭제된 서가',
-    'bookshelf_delete_notification'    => '서가 지움',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => '댓글 쓰기',
index 0bff8724c955ed3f840e29c17cb02108e0d3bd31..1bb8a8538469f46b54ca5cdf0ce3ffa44eda56f8 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => '메일 주소',
     'password' => '비밀번호',
     'password_confirm' => '비밀번호 확인',
-    'password_hint' => '일곱 글자를 넘어야 합니다.',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => '비밀번호를 잊었나요?',
     'remember_me' => '로그인 유지',
     'ldap_email_hint' => '이 계정에 대한 메일 주소를 입력하세요.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => '이 메일 주소로는 이 사이트에 접근할 수 없습니다.',
     'register_success' => '가입했습니다! 이제 로그인할 수 있습니다.',
 
-
     // Password Reset
     'reset_password' => '비밀번호 바꾸기',
     'reset_password_send_instructions' => '메일 주소를 입력하세요. 이 주소로 해당 과정을 위한 링크를 보낼 것입니다.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => '비밀번호를 바꿉니다.',
     'email_reset_not_requested' => '원하지 않는다면 이 과정은 필요 없습니다.',
 
-
     // Email Confirmation
     'email_confirm_subject' => ':appName 메일 인증',
     'email_confirm_greeting' => ':appName로 가입해 주셔서 감사합니다!',
     'email_confirm_text' => '다음 버튼을 눌러 인증하세요:',
     'email_confirm_action' => '메일 인증',
     'email_confirm_send_error' => '메일을 보낼 수 없었습니다.',
-    'email_confirm_success' => '인증했습니다!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => '다시 보냈습니다. 메일함을 확인하세요.',
 
     'email_not_confirmed' => '인증하지 않았습니다.',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => ':appName에 오신 것을 환영합니다!',
     'user_invite_page_text' => ':appName에 로그인할 때 입력할 비밀번호를 설정하세요.',
     'user_invite_page_confirm_button' => '비밀번호 확인',
-    'user_invite_success' => '암호가 설정되었고, 이제 :appName에 접근할 수 있습니다.'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index bb304bea11d5a4314b3423ec0c8e17c27decd3cc..cc318041f8168923920a18d0ab51321d69092966 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => '권한',
     'cover_image' => '대표 이미지',
     'cover_image_description' => '이미지 규격은 440x250px 내외입니다.',
-    
+
     // Actions
     'actions' => '활동',
     'view' => '보기',
@@ -39,7 +39,14 @@ return [
     'reset' => '리셋',
     'remove' => '제거',
     'add' => '추가',
+    'configure' => 'Configure',
     'fullscreen' => '전체화면',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => '정렬 기준',
@@ -56,6 +63,7 @@ return [
     'no_activity' => '활동 없음',
     'no_items' => '항목 없음',
     'back_to_top' => '맨 위로',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => '내용 보기',
     'toggle_thumbnails' => '섬네일 보기',
     'details' => '정보',
@@ -63,6 +71,11 @@ return [
     'list_view' => '목록 보기',
     'default' => '기본 설정',
     'breadcrumb' => '탐색 경로',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index e2fa0c7ae7c860c3b0a500dad7dc55c88c42ae5e..87ef6e5b73b0de861496c56ef131342deed8e4cd 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => '이미지',
     'my_recent_drafts' => '내 최근의 초안 문서',
     'my_recently_viewed' => '내가 읽은 문서',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => '문서 없음',
     'no_pages_recently_created' => '문서 없음',
     'no_pages_recently_updated' => '문서 없음',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Contained Web(.html) 파일',
     'export_pdf' => 'PDF 파일',
     'export_text' => 'Plain Text(.txt) 파일',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => '권한',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => '서가 권한',
     'shelves_permissions_updated' => '서가 권한 바꿈',
     'shelves_permissions_active' => '서가 권한 허용함',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => '권한 맞춤',
     'shelves_copy_permissions' => '실행',
     'shelves_copy_permissions_explain' => '서가의 모든 책자에 이 권한을 적용합니다. 서가의 권한을 저장했는지 확인하세요.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => '문서 우선',
     'books_sort_show_other' => '다른 책자들',
     'books_sort_save' => '적용',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => '챕터',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => '챕터 이동하기',
     'chapters_move_named' => ':chapterName 이동하기',
     'chapter_move_success' => ':bookName(으)로 옮김',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => '챕터 권한',
     'chapters_empty' => '이 챕터에 문서가 없습니다.',
     'chapters_permissions_active' => '문서 권한 허용함',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => '제목 없음',
     'pages_editing_draft_notification' => ':timeDiff에 초안 문서입니다.',
     'pages_draft_edited_notification' => '최근에 수정한 문서이기 때문에 초안 문서를 폐기하는 편이 좋습니다.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count명이 이 문서를 수정하고 있습니다.',
         'start_b' => ':userName이 이 문서를 수정하고 있습니다.',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "태그로 문서를 분류하세요.",
     'tags_add' => '태그 추가',
     'tags_remove' => '태그 삭제',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => '첨부 파일',
     'attachments_explain' => '파일이나 링크를 첨부하세요. 정보 탭에 나타납니다.',
     'attachments_explain_instant_save' => '여기에서 바꾼 내용은 바로 적용합니다.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => '이 수정본을 지울 건가요?',
     'revision_restore_confirm' => '이 수정본을 되돌릴 건가요? 현재 판본을 바꿉니다.',
     'revision_delete_success' => '수정본 지움',
-    'revision_cannot_delete_latest' => '현재 판본은 지울 수 없습니다.'
+    'revision_cannot_delete_latest' => '현재 판본은 지울 수 없습니다.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 093288c83a7f59f52c3db9dceec1475be5320bc4..b52ee91bc0e43d847a6ef22390141ab79838a955 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => '이 사용자에 대하여 외부 인증시스템에 의해 제공된 데이타 중 이메일 주소를 찾을 수 없습니다.',
     'saml_invalid_response_id' => '이 응용프로그램에 의해 시작된 프로세스에 의하면 외부 인증시스템으로 온 요청이 인식되지 않습니다. 인증 후에 뒤로가기 기능을 사용했을 경우 이런 현상이 발생할 수 있습니다.',
     'saml_fail_authed' => '시스템 로그인에 실패하였습니다. ( 해당 시스템이 인증성공값을 제공하지 않았습니다. )',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => '무슨 활동인지 알 수 없습니다.',
     'social_login_bad_response' => ":socialAccount에 로그인할 수 없습니다. : \\n:error",
     'social_account_in_use' => ':socialAccount(을)를 가진 사용자가 있습니다. :socialAccount로 로그인하세요.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => '404 Not Found',
     'sorry_page_not_found' => '문서를 못 찾았습니다.',
     'sorry_page_not_found_permission_warning' => '이 페이지가 존재하기를 기대했다면, 볼 수 있는 권한이 없을 수 있다.',
+    'image_not_found' => 'Image Not Found',
+    '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' => '처음으로 돌아가기',
     'error_occurred' => '문제가 생겼습니다.',
     'app_down' => ':appName에 문제가 있는 것 같습니다',
index 920ce04506c6410e716a30cf02b14bf3c5b4b685..11c2da928d9264030171be2952f5e296a4b3bcd4 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => '데이터',
     'maint_image_cleanup' => '이미지 정리',
-    'maint_image_cleanup_desc' => "중복한 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.",
+    'maint_image_cleanup_desc' => '중복한 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => '실행',
     'maint_image_cleanup_warning' => '이미지 :count개를 지울 건가요?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Recycle Bin',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
     'recycle_bin_deleted_item' => 'Deleted Item',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Deleted By',
     'recycle_bin_deleted_at' => 'Deletion Time',
     'recycle_bin_permanently_delete' => 'Permanently Delete',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items to be Restored',
     'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => '사용자',
     'audit_table_event' => '이벤트',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => '활동 날짜',
     'audit_date_from' => '날짜 범위 시작',
     'audit_date_to' => '날짜 범위 끝',
@@ -136,6 +139,7 @@ return [
     'role_details' => '권한 정보',
     'role_name' => '권한 이름',
     'role_desc' => '설명',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'LDAP 확인',
     'role_system' => '시스템 권한',
     'role_manage_users' => '사용자 관리',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => '템플릿 관리',
     'role_access_api' => '시스템 접근 API',
     'role_manage_settings' => '사이트 설정 관리',
+    'role_export_content' => 'Export content',
     'role_asset' => '권한 항목',
     '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' => '책자, 챕터, 문서별 권한은 이 설정에 우선합니다.',
@@ -169,7 +174,7 @@ return [
     'users_role' => '사용자 권한',
     'users_role_desc' => '고른 권한 모두를 적용합니다.',
     'users_password' => '사용자 비밀번호',
-    'users_password_desc' => '여섯 글자를 넘어야 합니다.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => '비밀번호 설정을 권유하는 메일을 보내거나 내가 정할 수 있습니다.',
     'users_send_invite_option' => '메일 보내기',
     'users_external_auth_id' => 'LDAP 확인',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => '토큰 만들기',
     'users_api_tokens_expires' => '만료',
     'users_api_tokens_docs' => 'API 설명서',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'API 토큰 만들기',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => '이 API 토큰을 삭제하시겠습니까?',
     'user_api_token_delete_success' => 'API 토큰이 성공적으로 삭제되었다.',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => '히브리어',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 6754d962069082ea2884d8875a5a8980f143ae15..ef8328103fe990081b50eb574f0e7e180c9aa937 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute(을)를 문자, 숫자, -, _로만 구성하세요.',
     'alpha_num'            => ':attribute(을)를 문자, 숫자로만 구성하세요.',
     'array'                => ':attribute(을)를 배열로 구성하세요.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute(을)를 :date 전으로 설정하세요.',
     'between'              => [
         'numeric' => ':attribute(을)를 :min~:max(으)로 구성하세요.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute(을)를 문자로 구성하세요.',
     'timezone'             => ':attribute(을)를 유효한 시간대로 구성하세요.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute(은)는 이미 있습니다.',
     'url'                  => ':attribute(은)는 유효하지 않은 형식입니다.',
     'uploaded'             => '파일 크기가 서버에서 허용하는 수치를 넘습니다.',
diff --git a/resources/lang/lt/activities.php b/resources/lang/lt/activities.php
new file mode 100644 (file)
index 0000000..985dd02
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'sukurtas puslapis',
+    'page_create_notification'    => 'Page successfully created',
+    'page_update'                 => 'atnaujintas puslapis',
+    'page_update_notification'    => 'Page successfully updated',
+    'page_delete'                 => 'ištrintas puslapis',
+    'page_delete_notification'    => 'Page successfully deleted',
+    'page_restore'                => 'atkurtas puslapis',
+    'page_restore_notification'   => 'Page successfully restored',
+    'page_move'                   => 'perkeltas puslapis',
+
+    // Chapters
+    'chapter_create'              => 'sukurtas skyrius',
+    'chapter_create_notification' => 'Chapter successfully created',
+    'chapter_update'              => 'atnaujintas skyrius',
+    'chapter_update_notification' => 'Chapter successfully updated',
+    'chapter_delete'              => 'ištrintas skyrius',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
+    'chapter_move'                => 'perkeltas skyrius',
+
+    // Books
+    'book_create'                 => 'sukurta knyga',
+    'book_create_notification'    => 'Book successfully created',
+    'book_update'                 => 'atnaujinta knyga',
+    'book_update_notification'    => 'Book successfully updated',
+    'book_delete'                 => 'ištrinta knyga',
+    'book_delete_notification'    => 'Book successfully deleted',
+    'book_sort'                   => 'surūšiuota knyga',
+    'book_sort_notification'      => 'Book successfully re-sorted',
+
+    // Bookshelves
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
+    'bookshelf_update'                 => 'atnaujinta knygų lentyna',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
+    'bookshelf_delete'                 => 'ištrinta knygų lentyna',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
+
+    // Other
+    'commented_on'                => 'pakomentavo',
+    'permissions_update'          => 'atnaujinti leidimai',
+];
diff --git a/resources/lang/lt/auth.php b/resources/lang/lt/auth.php
new file mode 100644 (file)
index 0000000..e6b2233
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Authentication Language Lines
+ * The following language lines are used during authentication for various
+ * messages that we need to display to the user.
+ */
+return [
+
+    'failed' => 'Šie įgaliojimai neatitinka mūsų įrašų.',
+    'throttle' => 'Per daug prisijungimo bandymų. Prašome pabandyti dar kartą po :seconds sekundžių.',
+
+    // Login & Register
+    'sign_up' => 'Užsiregistruoti',
+    'log_in' => 'Prisijungti',
+    'log_in_with' => 'Prisijungti su :socialDriver',
+    'sign_up_with' => 'Užsiregistruoti su :socialDriver',
+    'logout' => 'Atsijungti',
+
+    'name' => 'Pavadinimas',
+    'username' => 'Vartotojo vardas',
+    'email' => 'Elektroninis paštas',
+    'password' => 'Slaptažodis',
+    'password_confirm' => 'Patvirtinti slaptažodį',
+    'password_hint' => 'Must be at least 8 characters',
+    'forgot_password' => 'Pamiršote slaptažodį?',
+    'remember_me' => 'Prisimink mane',
+    'ldap_email_hint' => 'Prašome įvesti elektroninį paštą, kad galėtume naudotis šia paskyra.',
+    'create_account' => 'Sukurti paskyrą',
+    'already_have_account' => 'Jau turite paskyrą?',
+    'dont_have_account' => 'Neturite paskyros?',
+    'social_login' => 'Socialinis prisijungimas',
+    'social_registration' => 'Socialinė registracija',
+    'social_registration_text' => 'Užsiregistruoti ir prisijungti naudojantis kita paslauga.',
+
+    'register_thanks' => 'Ačiū, kad užsiregistravote!',
+    'register_confirm' => 'Prašome patikrinti savo elektroninį paštą ir paspausti patvirtinimo mygtuką, kad gautumėte leidimą į :appName.',
+    'registrations_disabled' => 'Registracijos šiuo metu negalimos',
+    'registration_email_domain_invalid' => 'Elektroninio pašto domenas neturi prieigos prie šios programos',
+    'register_success' => 'Ačiū už prisijungimą! Dabar jūs užsiregistravote ir prisijungėte.',
+
+    // Password Reset
+    'reset_password' => 'Pakeisti slaptažodį',
+    'reset_password_send_instructions' => 'Įveskite savo elektroninį paštą žemiau ir jums bus išsiųstas elektroninis laiškas su slaptažodžio nustatymo nuoroda.',
+    'reset_password_send_button' => 'Atsiųsti atsatymo nuorodą',
+    'reset_password_sent' => 'Slaptažodžio nustatymo nuoroda bus išsiųsta :email jeigu elektroninio pašto adresas bus rastas sistemoje.',
+    'reset_password_success' => 'Jūsų slaptažodis buvo sėkmingai atnaujintas.',
+    'email_reset_subject' => 'Atnaujinti jūsų :appName slaptažodį',
+    'email_reset_text' => 'Šį laišką gaunate, nes mes gavome slaptažodžio atnaujinimo užklausą iš jūsų paskyros.',
+    'email_reset_not_requested' => 'Jeigu jums nereikia slaptažodžio atnaujinimo, tolimesnių veiksmų atlikti nereikia.',
+
+    // Email Confirmation
+    'email_confirm_subject' => 'Patvirtinkite savo elektroninį paštą :appName',
+    'email_confirm_greeting' => 'Ačiū už prisijungimą prie :appName!',
+    'email_confirm_text' => 'Prašome patvirtinti savo elektroninio pašto adresą paspaudus mygtuką žemiau:',
+    'email_confirm_action' => 'Patvirtinkite elektroninį paštą',
+    'email_confirm_send_error' => 'Būtinas elektroninio laiško patviritnimas, bet sistema negali išsiųsti laiško. Susisiekite su administratoriumi, kad užtikrintumėte, jog elektroninis paštas atsinaujino teisingai.',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
+    'email_confirm_resent' => 'Elektroninio pašto patvirtinimas persiųstas, prašome patikrinti pašto dėžutę.',
+
+    'email_not_confirmed' => 'Elektroninis paštas nepatvirtintas',
+    'email_not_confirmed_text' => 'Jūsų elektroninis paštas dar vis nepatvirtintas.',
+    'email_not_confirmed_click_link' => 'Prašome paspausti nuorodą elektroniniame pašte, kuri buvo išsiųsta iš karto po registracijos.',
+    'email_not_confirmed_resend' => 'Jeigu nerandate elektroninio laiško, galite dar kartą išsiųsti patvirtinimo elektroninį laišką, pateikdami žemiau esančią formą.',
+    'email_not_confirmed_resend_button' => 'Persiųsti patvirtinimo laišką',
+
+    // User Invite
+    'user_invite_email_subject' => 'Jūs buvote pakviestas prisijungti prie :appName!',
+    'user_invite_email_greeting' => 'Paskyra buvo sukurta jums :appName.',
+    'user_invite_email_text' => 'Paspauskite mygtuką žemiau, kad sukurtumėte paskyros slaptažodį ir gautumėte prieigą:',
+    'user_invite_email_action' => 'Sukurti paskyros slaptažodį',
+    'user_invite_page_welcome' => 'Sveiki atvykę į :appName!',
+    'user_invite_page_text' => 'Norėdami galutinai pabaigti paskyrą ir gauti prieigą jums reikia nustatyti slaptažodį, kuris bus naudojamas prisijungiant prie :appName ateities vizitų metu.',
+    'user_invite_page_confirm_button' => 'Patvirtinti slaptažodį',
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
diff --git a/resources/lang/lt/common.php b/resources/lang/lt/common.php
new file mode 100644 (file)
index 0000000..090b86a
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Atšaukti',
+    'confirm' => 'Patvirtinti',
+    'back' => 'Grįžti',
+    'save' => 'Išsaugoti',
+    'continue' => 'Praleisti',
+    'select' => 'Pasirinkti',
+    'toggle_all' => 'Perjungti visus',
+    'more' => 'Daugiau',
+
+    // Form Labels
+    'name' => 'Pavadinimas',
+    'description' => 'Apibūdinimas',
+    'role' => 'Vaidmuo',
+    'cover_image' => 'Viršelio nuotrauka',
+    'cover_image_description' => 'Ši nuotrauka turi būti maždaug 440x250px.',
+
+    // Actions
+    'actions' => 'Veiksmai',
+    'view' => 'Rodyti',
+    'view_all' => 'Rodyti visus',
+    'create' => 'Sukurti',
+    'update' => 'Atnaujinti',
+    'edit' => 'Redaguoti',
+    'sort' => 'Rūšiuoti',
+    'move' => 'Perkelti',
+    'copy' => 'Kopijuoti',
+    'reply' => 'Atsakyti',
+    'delete' => 'Ištrinti',
+    'delete_confirm' => 'Patvirtinti ištrynimą',
+    'search' => 'Paieška',
+    'search_clear' => 'Išvalyti paiešką',
+    'reset' => 'Atsatyti',
+    'remove' => 'Pašalinti',
+    'add' => 'Pridėti',
+    'configure' => 'Configure',
+    'fullscreen' => 'Visas ekranas',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
+
+    // Sort Options
+    'sort_options' => 'Rūšiuoti pasirinkimus',
+    'sort_direction_toggle' => 'Rūšiuoti krypties perjungimus',
+    'sort_ascending' => 'Rūšiuoti didėjančia tvarka',
+    'sort_descending' => 'Rūšiuoti mažėjančia tvarka',
+    'sort_name' => 'Pavadinimas',
+    'sort_default' => 'Numatytas',
+    'sort_created_at' => 'Sukurta data',
+    'sort_updated_at' => 'Atnaujinta data',
+
+    // Misc
+    'deleted_user' => 'Ištrinti naudotoją',
+    'no_activity' => 'Nėra veiklų',
+    'no_items' => 'Nėra elementų',
+    'back_to_top' => 'Grįžti į pradžią',
+    'skip_to_main_content' => 'Skip to main content',
+    'toggle_details' => 'Perjungti detales',
+    'toggle_thumbnails' => 'Perjungti miniatūras',
+    'details' => 'Detalės',
+    'grid_view' => 'Tinklelio vaizdas',
+    'list_view' => 'Sąrašas',
+    'default' => 'Numatytas',
+    'breadcrumb' => 'Duonos rėžis',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
+
+    // Header
+    'header_menu_expand' => 'Plėsti antraštės meniu',
+    'profile_menu' => 'Profilio meniu',
+    'view_profile' => 'Rodyti porofilį',
+    'edit_profile' => 'Redaguoti profilį',
+    'dark_mode' => 'Tamsus rėžimas',
+    'light_mode' => 'Šviesus rėžimas',
+
+    // Layout tabs
+    'tab_info' => 'Informacija',
+    'tab_info_label' => 'Skirtukas: Rodyti antrinę informaciją',
+    'tab_content' => 'Turinys',
+    'tab_content_label' => 'Skirtukas: Rodyti pirminę informaciją',
+
+    // Email Content
+    'email_action_help' => 'Jeigu kyla problemų spaudžiant :actionText: mygtuką, nukopijuokite ir įklijuokite URL į savo naršyklę.',
+    'email_rights' => 'Visos teisės rezervuotos',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privatumo politika',
+    'terms_of_service' => 'Paslaugų teikimo paslaugos',
+];
diff --git a/resources/lang/lt/components.php b/resources/lang/lt/components.php
new file mode 100644 (file)
index 0000000..ce573da
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Nuotraukų pasirinkimas',
+    'image_all' => 'Visi',
+    'image_all_title' => 'Rodyti visas nuotraukas',
+    'image_book_title' => 'Peržiūrėti nuotraukas, įkeltas į šią knygą',
+    'image_page_title' => 'Peržiūrėti nuotraukas, įkeltas į šį puslapį',
+    'image_search_hint' => 'Ieškoti pagal nuotraukos pavadinimą',
+    'image_uploaded' => 'Įkelta :uploadedDate',
+    'image_load_more' => 'Rodyti daugiau',
+    'image_image_name' => 'Nuotraukos pavadinimas',
+    'image_delete_used' => 'Ši nuotrauka yra naudojama puslapyje žemiau.',
+    'image_delete_confirm_text' => 'Ar jūs esate tikri, kad norite ištrinti šią nuotrauką?',
+    'image_select_image' => 'Pasirinkti nuotrauką',
+    'image_dropzone' => 'Tempkite nuotraukas arba spauskite šia, kad įkeltumėte',
+    'images_deleted' => 'Nuotraukos ištrintos',
+    'image_preview' => 'Nuotraukų peržiūra',
+    'image_upload_success' => 'Nuotrauka įkelta sėkmingai',
+    'image_update_success' => 'Nuotraukos detalės sėkmingai atnaujintos',
+    'image_delete_success' => 'Nuotrauka sėkmingai ištrinti',
+    'image_upload_remove' => 'Pašalinti',
+
+    // Code Editor
+    'code_editor' => 'Redaguoti kodą',
+    'code_language' => 'Kodo kalba',
+    'code_content' => 'Kodo turinys',
+    'code_session_history' => 'Sesijos istorija',
+    'code_save' => 'Išsaugoti kodą',
+];
diff --git a/resources/lang/lt/entities.php b/resources/lang/lt/entities.php
new file mode 100644 (file)
index 0000000..c9c1c60
--- /dev/null
@@ -0,0 +1,347 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Neseniai sukurtas',
+    'recently_created_pages' => 'Neseniai sukurti puslapiai',
+    'recently_updated_pages' => 'Neseniai atnaujinti puslapiai',
+    'recently_created_chapters' => 'Neseniai sukurti skyriai',
+    'recently_created_books' => 'Neseniai sukurtos knygos',
+    'recently_created_shelves' => 'Neseniai sukurtos lentynos',
+    'recently_update' => 'Neseniai atnaujinta',
+    'recently_viewed' => 'Neseniai peržiūrėta',
+    'recent_activity' => 'Paskutiniai veiksmai',
+    'create_now' => 'Sukurti vieną dabar',
+    'revisions' => 'Pataisymai',
+    'meta_revision' => 'Pataisymas #:revisionCount',
+    'meta_created' => 'Sukurta :timeLength',
+    'meta_created_name' => 'Sukurta :timeLength naudotojo :user',
+    'meta_updated' => 'Atnaujintas :timeLength',
+    'meta_updated_name' => 'Atnaujinta :timeLength naudotojo :user',
+    'meta_owned_name' => 'Priklauso :user',
+    'entity_select' => 'Pasirinkti subjektą',
+    'images' => 'Nuotraukos',
+    'my_recent_drafts' => 'Naujausi išsaugoti juodraščiai',
+    'my_recently_viewed' => 'Neseniai peržiūrėti',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'Mėgstamiausi',
+    'no_pages_viewed' => 'Jūs neperžiūrėjote nei vieno puslapio',
+    'no_pages_recently_created' => 'Nebuvos sukurta jokių puslapių',
+    'no_pages_recently_updated' => 'Nebuvo atnaujinta jokių puslapių',
+    'export' => 'Eksportuoti',
+    'export_html' => 'Sudėtinis žiniatinklio failas',
+    'export_pdf' => 'PDF failas',
+    'export_text' => 'Paprastas failo tekstas',
+    'export_md' => 'Markdown File',
+
+    // Permissions and restrictions
+    'permissions' => 'Leidimai',
+    'permissions_intro' => 'Įgalinus šias teises, pirmenybė bus teikiama visiems nustatytiems vaidmenų leidimams.',
+    'permissions_enable' => 'Įgalinti pasirinktus leidimus',
+    'permissions_save' => 'Išsaugoti leidimus',
+    'permissions_owner' => 'Savininkas',
+
+    // Search
+    'search_results' => 'Ieškoti rezultatų',
+    'search_total_results_found' => ':count rastas rezultatas|:count iš viso rezultatų rasta',
+    'search_clear' => 'Išvalyti paiešką',
+    'search_no_pages' => 'Nėra puslapių pagal šią paiešką',
+    'search_for_term' => 'Ieškoti pagal :term',
+    'search_more' => 'Daugiau rezultatų',
+    'search_advanced' => 'Išplėstinė paieška',
+    'search_terms' => 'Ieškoti terminų',
+    'search_content_type' => 'Turinio tipas',
+    'search_exact_matches' => 'Tikslūs atitikmenys',
+    'search_tags' => 'Žymių paieškos',
+    'search_options' => 'Parinktys',
+    'search_viewed_by_me' => 'Mano peržiūrėta',
+    'search_not_viewed_by_me' => 'Mano neperžiūrėta',
+    'search_permissions_set' => 'Nustatyti leidimus',
+    'search_created_by_me' => 'Mano sukurta',
+    'search_updated_by_me' => 'Mano atnaujinimas',
+    'search_owned_by_me' => 'Priklauso man',
+    'search_date_options' => 'Datos parinktys',
+    'search_updated_before' => 'Atnaujinta prieš',
+    'search_updated_after' => 'Atnaujinta po',
+    'search_created_before' => 'Sukurta prieš',
+    'search_created_after' => 'Sukurta po',
+    'search_set_date' => 'Nustatyti datą',
+    'search_update' => 'Atnaujinti paiešką',
+
+    // Shelves
+    'shelf' => 'Lentyna',
+    'shelves' => 'Lentynos',
+    'x_shelves' => ':count lentyna|:count lentynos',
+    'shelves_long' => 'Knygų lentynos',
+    'shelves_empty' => 'Nebuvo sukurtos jokios lentynos',
+    'shelves_create' => 'Sukurti naują lentyną',
+    'shelves_popular' => 'Populiarios lentynos',
+    'shelves_new' => 'Naujos lentynos',
+    'shelves_new_action' => 'Nauja lentyna',
+    'shelves_popular_empty' => 'Populiariausios knygos pasirodys čia.',
+    'shelves_new_empty' => 'Visai neseniai sukurtos lentynos pasirodys čia.',
+    'shelves_save' => 'Išsaugoti lenyną',
+    'shelves_books' => 'Knygos šioje lentynoje',
+    'shelves_add_books' => 'Pridėti knygas į šią lentyną',
+    'shelves_drag_books' => 'Vilkite knygas čia, kad pridėtumėte jas į šią lentyną',
+    'shelves_empty_contents' => 'Ši lentyną neturi jokių pridėtų knygų',
+    'shelves_edit_and_assign' => 'Redaguoti lentyną, kad pridėti knygų',
+    'shelves_edit_named' => 'Redaguoti knygų lentyną :name',
+    'shelves_edit' => 'Redaguoti knygų lentyną',
+    'shelves_delete' => 'Ištrinti knygų lentyną',
+    'shelves_delete_named' => 'Ištrinti knygų lentyną :name',
+    'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
+    'shelves_delete_confirmation' => 'Ar jūs esate tikri, kad norite ištrinti šią knygų lentyną?',
+    'shelves_permissions' => 'Knygų lentynos leidimai',
+    'shelves_permissions_updated' => 'Knygų lentynos leidimai atnaujinti',
+    'shelves_permissions_active' => 'Knygų lentynos leidimai aktyvūs',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
+    'shelves_copy_permissions_to_books' => 'Kopijuoti leidimus knygoms',
+    'shelves_copy_permissions' => 'Kopijuoti leidimus',
+    'shelves_copy_permissions_explain' => 'Visoms knygoms, esančioms šioje knygų lentynoje, bus taikomi dabartiniai leidimų nustatymai. Prieš suaktyvindami įsitikinkite, kad visi šios knygų lentynos leidimų pakeitimai buvo išsaugoti.',
+    'shelves_copy_permission_success' => 'Knygų lentynos leidimai nukopijuoti į :count knygas',
+
+    // Books
+    'book' => 'Knyga',
+    'books' => 'Knygos',
+    'x_books' => ':count knyga|:count knygos',
+    'books_empty' => 'Nebuvo sukurta jokių knygų',
+    'books_popular' => 'Populiarios knygos',
+    'books_recent' => 'Naujos knygos',
+    'books_new' => 'Naujos knygos',
+    'books_new_action' => 'Nauja knyga',
+    'books_popular_empty' => 'Čia pasirodys pačios populiariausios knygos.',
+    'books_new_empty' => 'Čia pasirodys naujausios sukurtos knygos',
+    'books_create' => 'Sukurti naują knygą',
+    'books_delete' => 'Ištrinti knygą',
+    'books_delete_named' => 'Ištrinti knygą :bookName',
+    'books_delete_explain' => 'Tai ištrins knygą su pavadinimu \':bookName\'. Visi puslapiai ir skyriai bus pašalinti.',
+    'books_delete_confirmation' => 'Ar jūs esate tikri, kad norite ištrinti šią knygą?',
+    'books_edit' => 'Redaguoti knygą',
+    'books_edit_named' => 'Redaguoti knygą :bookName',
+    'books_form_book_name' => 'Knygos pavadinimas',
+    'books_save' => 'Išsaugoti knygą',
+    'books_permissions' => 'Knygos leidimas',
+    'books_permissions_updated' => 'Knygos leidimas atnaujintas',
+    'books_empty_contents' => 'Jokių puslapių ar skyrių nebuvo skurta šiai knygai',
+    'books_empty_create_page' => 'Sukurti naują puslapį',
+    'books_empty_sort_current_book' => 'Rūšiuoti dabartinę knygą',
+    'books_empty_add_chapter' => 'Pridėti skyrių',
+    'books_permissions_active' => 'Knygos leidimas aktyvus',
+    'books_search_this' => 'Ieškoti šioje knygoje',
+    'books_navigation' => 'Knygos naršymas',
+    'books_sort' => 'Rūšiuoti pagal knygos turinį',
+    'books_sort_named' => 'Rūšiuoti knygą :bookName',
+    'books_sort_name' => 'Rūšiuoti pagal vardą',
+    'books_sort_created' => 'Rūšiuoti pagal sukūrimo datą',
+    'books_sort_updated' => 'Rūšiuoti pagal atnaujinimo datą',
+    'books_sort_chapters_first' => 'Skyriaus pradžia',
+    'books_sort_chapters_last' => 'Skyriaus pabaiga',
+    'books_sort_show_other' => 'Rodyti kitas knygas',
+    'books_sort_save' => 'Išsaugoti naują įsakymą',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
+
+    // Chapters
+    'chapter' => 'Skyrius',
+    'chapters' => 'Skyriai',
+    'x_chapters' => ':count skyrius|:count skyriai',
+    'chapters_popular' => 'Populiarūs skyriai',
+    'chapters_new' => 'Naujas skyrius',
+    'chapters_create' => 'Sukurti naują skyrių',
+    'chapters_delete' => 'Ištrinti skyrių',
+    'chapters_delete_named' => 'Ištrinti skyrių :chapterName',
+    'chapters_delete_explain' => 'Tai ištrins skyrių su pavadinimu \':chapterName\. Visi puslapiai, esantys šiame skyriuje, taip pat bus ištrinti.',
+    'chapters_delete_confirm' => 'Ar esate tikri, jog norite ištrinti šį skyrių?',
+    'chapters_edit' => 'Redaguoti skyrių',
+    'chapters_edit_named' => 'Redaguoti skyrių :chapterName',
+    'chapters_save' => 'Išsaugoti skyrių',
+    'chapters_move' => 'Perkelti skyrių',
+    'chapters_move_named' => 'Perkelti skyrių :chapterName',
+    'chapter_move_success' => 'Skyrius perkeltas į :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
+    'chapters_permissions' => 'Skyriaus leidimai',
+    'chapters_empty' => 'Šiuo metu skyriuje nėra puslapių',
+    'chapters_permissions_active' => 'Skyriaus leidimai aktyvūs',
+    'chapters_permissions_success' => 'Skyriaus leidimai atnaujinti',
+    'chapters_search_this' => 'Ieškoti šio skyriaus',
+
+    // Pages
+    'page' => 'Puslapis',
+    'pages' => 'Puslapiai',
+    'x_pages' => ':count puslapis|:count puslapiai',
+    'pages_popular' => 'Populiarūs puslapiai',
+    'pages_new' => 'Naujas puslapis',
+    'pages_attachments' => 'Priedai',
+    'pages_navigation' => 'Puslapių navigacija',
+    'pages_delete' => 'Ištrinti puslapį',
+    'pages_delete_named' => 'Ištrinti puslapį :pageName',
+    'pages_delete_draft_named' => 'Ištrinti juodraščio puslapį :pageName',
+    'pages_delete_draft' => 'Ištrinti juodraščio puslapį',
+    'pages_delete_success' => 'Puslapis ištrintas',
+    'pages_delete_draft_success' => 'Juodraščio puslapis ištrintas',
+    'pages_delete_confirm' => 'Ar esate tikri, kad norite ištrinti šį puslapį?',
+    'pages_delete_draft_confirm' => 'Ar esate tikri, kad norite ištrinti šį juodraščio puslapį?',
+    'pages_editing_named' => 'Redaguojamas puslapis :pageName',
+    'pages_edit_draft_options' => 'Juodrasčio pasirinkimai',
+    'pages_edit_save_draft' => 'Išsaugoti juodraštį',
+    'pages_edit_draft' => 'Redaguoti juodraščio puslapį',
+    'pages_editing_draft' => 'Redaguojamas juodraštis',
+    'pages_editing_page' => 'Redaguojamas puslapis',
+    'pages_edit_draft_save_at' => 'Juodraštis išsaugotas',
+    'pages_edit_delete_draft' => 'Ištrinti juodraštį',
+    'pages_edit_discard_draft' => 'Išmesti juodraštį',
+    'pages_edit_set_changelog' => 'Nustatyti keitimo žurnalą',
+    'pages_edit_enter_changelog_desc' => 'Įveskite trumpus, jūsų atliktus, pokyčių aprašymus',
+    'pages_edit_enter_changelog' => 'Įeiti į keitimo žurnalą',
+    'pages_save' => 'Išsaugoti puslapį',
+    'pages_title' => 'Puslapio antraštė',
+    'pages_name' => 'Puslapio pavadinimas',
+    'pages_md_editor' => 'Redaguotojas',
+    'pages_md_preview' => 'Peržiūra',
+    'pages_md_insert_image' => 'Įterpti nuotrauką',
+    'pages_md_insert_link' => 'Įterpti subjekto nuorodą',
+    'pages_md_insert_drawing' => 'Įterpti piešinį',
+    'pages_not_in_chapter' => 'Puslapio nėra skyriuje',
+    'pages_move' => 'Perkelti puslapį',
+    'pages_move_success' => 'Puslapis perkeltas į ":parentName"',
+    'pages_copy' => 'Nukopijuoti puslapį',
+    'pages_copy_desination' => 'Nukopijuoti tikslą',
+    'pages_copy_success' => 'Puslapis sėkmingai nukopijuotas',
+    'pages_permissions' => 'Puslapio leidimai',
+    'pages_permissions_success' => 'Puslapio leidimai atnaujinti',
+    'pages_revision' => 'Peržiūra',
+    'pages_revisions' => 'Puslapio peržiūros',
+    'pages_revisions_named' => 'Peržiūros puslapio :pageName',
+    'pages_revision_named' => 'Peržiūra puslapio :pageName',
+    'pages_revision_restored_from' => 'Atkurta iš #:id; :summary',
+    'pages_revisions_created_by' => 'Sukurta',
+    'pages_revisions_date' => 'Peržiūros data',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Peržiūra #:id',
+    'pages_revisions_numbered_changes' => 'Peržiūros #:id pokyčiai',
+    'pages_revisions_changelog' => 'Keitimo žurnalas',
+    'pages_revisions_changes' => 'Pakeitimai',
+    'pages_revisions_current' => 'Dabartinė versija',
+    'pages_revisions_preview' => 'Peržiūra',
+    'pages_revisions_restore' => 'Atkurti',
+    'pages_revisions_none' => 'Šis puslapis neturi peržiūrų',
+    'pages_copy_link' => 'Kopijuoti nuorodą',
+    'pages_edit_content_link' => 'Redaguoti turinį',
+    'pages_permissions_active' => 'Puslapio leidimai aktyvūs',
+    'pages_initial_revision' => 'Pradinis skelbimas',
+    'pages_initial_name' => 'Naujas puslapis',
+    'pages_editing_draft_notification' => 'Dabar jūs redaguojate juodraštį, kuris paskutinį kartą buvo išsaugotas :timeDiff',
+    'pages_draft_edited_notification' => 'Šis puslapis buvo redaguotas iki to laiko. Rekomenduojame jums išmesti šį juodraštį.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count naudotojai pradėjo redaguoti šį puslapį',
+        'start_b' => ':userName pradėjo redaguoti šį puslapį',
+        'time_a' => 'nuo puslapio paskutinio atnaujinimo',
+        'time_b' => 'paskutinėmis :minCount minutėmis',
+        'message' => ':start :time. Pasistenkite neperrašyti vienas kito atnaujinimų!',
+    ],
+    'pages_draft_discarded' => 'Juodraštis atmestas, redaguotojas atnaujintas dabartinis puslapio turinys',
+    'pages_specific' => 'Specifinis puslapis',
+    'pages_is_template' => 'Puslapio šablonas',
+
+    // Editor Sidebar
+    'page_tags' => 'Puslapio žymos',
+    'chapter_tags' => 'Skyriaus žymos',
+    'book_tags' => 'Knygos žymos',
+    'shelf_tags' => 'Lentynų žymos',
+    'tag' => 'Žymos',
+    'tags' =>  'Žymės',
+    'tag_name' =>  'Žymės pavadinimas',
+    'tag_value' => 'Žymos vertė (neprivaloma)',
+    'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
+    'tags_add' => 'Pridėti kitą žymą',
+    'tags_remove' => 'Pridėti kitą žymą',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'Visos reikšmės',
+    'tags_view_tags' => 'Peržiūrėti žymes',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
+    'attachments' => 'Priedai',
+    'attachments_explain' => 'Įkelkite kelis failus arba pridėkite nuorodas savo puslapyje. Jie matomi puslapio šoninėje juostoje.',
+    'attachments_explain_instant_save' => 'Pakeitimai čia yra išsaugomi akimirksniu.',
+    'attachments_items' => 'Pridėti elementai',
+    'attachments_upload' => 'Įkelti failą',
+    'attachments_link' => 'Pridėti nuorodą',
+    'attachments_set_link' => 'Nustatyti nuorodą',
+    'attachments_delete' => 'Ar esate tikri, kad norite ištrinti šį priedą?',
+    'attachments_dropzone' => 'Numesti failus arba paspausti čia ir pridėti failą',
+    'attachments_no_files' => 'Failai nebuvo įkelti',
+    'attachments_explain_link' => 'Jūs galite pridėti nuorodas, jei nenorite įkelti failo. Tai gali būti nuoroda į kitą puslapį arba nuoroda į failą debesyje.',
+    'attachments_link_name' => 'Nuorodos pavadinimas',
+    'attachment_link' => 'Priedo nuoroda',
+    'attachments_link_url' => 'Nuoroda į failą',
+    'attachments_link_url_hint' => 'URL į failą',
+    'attach' => 'Pridėti',
+    'attachments_insert_link' => 'Pridėti priedo nuorodą į puslapį',
+    'attachments_edit_file' => 'Redaguoti failą',
+    'attachments_edit_file_name' => 'Failo pavadinimas',
+    'attachments_edit_drop_upload' => 'Numesti failus arba spausti čia ir atsisiųsti ir perrašyti',
+    'attachments_order_updated' => 'Atnaujintas priedų išsidėstymas',
+    'attachments_updated_success' => 'Priedų detalės atnaujintos',
+    'attachments_deleted' => 'Priedas ištrintas',
+    'attachments_file_uploaded' => 'Failas sėkmingai įkeltas',
+    'attachments_file_updated' => 'Failas sėkmingai atnaujintas',
+    'attachments_link_attached' => 'Nuoroda sėkmingai pridėta puslapyje',
+    'templates' => 'Šablonai',
+    'templates_set_as_template' => 'Puslapis yra šablonas',
+    'templates_explain_set_as_template' => 'Jūs galite nustatyti šį puslapį kaip šabloną, jo turinys bus panaudotas, kuriant kitus puslapius. Kiti naudotojai galės naudotis šiuo šablonu, jei turės peržiūros leidimą šiam puslapiui.',
+    'templates_replace_content' => 'Pakeisti puslapio turinį',
+    'templates_append_content' => 'Papildyti puslapio turinį',
+    'templates_prepend_content' => 'Priklauso nuo puslapio turinio',
+
+    // Profile View
+    'profile_user_for_x' => 'Naudotojas :time',
+    'profile_created_content' => 'Sukurtas tyrinys',
+    'profile_not_created_pages' => ':userName nesukūrė jokio puslapio',
+    'profile_not_created_chapters' => ':userName nesukūrė jokio skyriaus',
+    'profile_not_created_books' => ':userName nesukūrė jokios knygos',
+    'profile_not_created_shelves' => ':userName nesukūrė jokių lentynų',
+
+    // Comments
+    'comment' => 'Komentaras',
+    'comments' => 'Komentarai',
+    'comment_add' => 'Pridėti komentarą',
+    'comment_placeholder' => 'Palikite komentarą čia',
+    'comment_count' => '{0} Nėra komentarų|{1} 1 komentaras|[2,*] :count komentarai',
+    'comment_save' => 'Išsaugoti komentarą',
+    'comment_saving' => 'Komentaras išsaugojamas...',
+    'comment_deleting' => 'Komentaras ištrinamas...',
+    'comment_new' => 'Naujas komentaras',
+    'comment_created' => 'Pakomentuota :createDiff',
+    'comment_updated' => 'Atnaujinta :updateDiff pagal :username',
+    'comment_deleted_success' => 'Komentaras ištrintas',
+    'comment_created_success' => 'Komentaras pridėtas',
+    'comment_updated_success' => 'Komentaras atnaujintas',
+    'comment_delete_confirm' => 'Esate tikri, kad norite ištrinti šį komentarą?',
+    'comment_in_reply_to' => 'Atsakydamas į :commentId',
+
+    // Revision
+    'revision_delete_confirm' => 'Esate tikri, kad norite ištrinti šią peržiūrą?',
+    'revision_restore_confirm' => 'Esate tikri, kad norite atkurti šią peržiūrą? Dabartinis puslapio turinys bus pakeistas.',
+    'revision_delete_success' => 'Peržiūra ištrinta',
+    'revision_cannot_delete_latest' => 'Negalima išrinti vėliausios peržiūros',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
+];
diff --git a/resources/lang/lt/errors.php b/resources/lang/lt/errors.php
new file mode 100644 (file)
index 0000000..1ceeb03
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Jūs neturite leidimo atidaryti šio puslapio.',
+    'permissionJson' => 'Jūs neturite leidimo atlikti prašomo veiksmo.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Naudotojo elektroninis paštas :email jau egzistuoja, bet su kitokiais įgaliojimais.',
+    'email_already_confirmed' => 'Elektroninis paštas jau buvo patvirtintas, pabandykite prisijungti.',
+    'email_confirmation_invalid' => 'Šis patvirtinimo prieigos raktas negalioja arba jau buvo panaudotas, prašome bandykite vėl registruotis.',
+    'email_confirmation_expired' => 'Šis patvirtinimo prieigos raktas baigė galioti, naujas patvirtinimo laiškas jau išsiųstas elektroniniu paštu.',
+    'email_confirmation_awaiting' => 'Elektroninio pašto adresą paskyrai reikia patvirtinti',
+    'ldap_fail_anonymous' => 'Nepavyko pasiekti LDAP naudojant anoniminį susiejimą',
+    'ldap_fail_authed' => 'Nepavyko pasiekti LDAP naudojant išsamią dn ir slaptažodžio informaciją',
+    'ldap_extension_not_installed' => 'LDAP PHP išplėtimas neįdiegtas',
+    'ldap_cannot_connect' => 'Negalima prisijungti prie LDAP serverio, nepavyko prisijungti',
+    'saml_already_logged_in' => 'Jau prisijungta',
+    'saml_user_not_registered' => 'Naudotojas :name neužregistruotas ir automatinė registracija yra išjungta',
+    '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_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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',
+    'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
+    'social_account_in_use' => 'Ši :socialAccount paskyra jau yra naudojama, pabandykite prisijungti per :socialAccount pasirinkimą.',
+    'social_account_email_in_use' => 'Elektroninis paštas :email jau yra naudojamas. Jei jūs jau turite paskyrą, galite prijungti savo :socialAccount paskyrą iš savo profilio nustatymų.',
+    'social_account_existing' => 'Šis :socialAccount jau yra pridėtas prie jūsų profilio.',
+    'social_account_already_used_existing' => 'Ši :socialAccount paskyra jau yra naudojama kito naudotojo.',
+    'social_account_not_used' => 'Ši :socialAccount paskyra nėra susieta su jokiais naudotojais. Prašome, pridėkite ją į savo profilio nustatymus.',
+    'social_account_register_instructions' => 'Jei dar neturite paskyros, galite užregistruoti paskyrą, naudojant :socialAccount pasirinkimą.',
+    'social_driver_not_found' => 'Socialinis diskas nerastas',
+    'social_driver_not_configured' => 'Jūsų :socialAccount socaliniai nustatymai sukonfigūruoti neteisingai.',
+    'invite_token_expired' => 'Ši kvietimo nuoroda baigė galioti. Vietoj to, jūs galite bandyti iš naujo nustatyti savo paskyros slaptažodį.',
+
+    // System
+    'path_not_writable' => 'Į failo kelią :filePath negalima įkelti. Įsitikinkite, kad jis yra įrašomas į serverį.',
+    'cannot_get_image_from_url' => 'Negalima gauti vaizdo iš :url',
+    'cannot_create_thumbs' => 'Serveris negali sukurti miniatiūros. Prašome patikrinkite, ar turite įdiegtą GD PHP plėtinį.',
+    'server_upload_limit' => 'Serveris neleidžia įkelti tokio dydžio failų. Prašome bandykite mažesnį failo dydį.',
+    'uploaded'  => 'Serveris neleidžia įkelti tokio dydžio failų. Prašome bandykite mažesnį failo dydį.',
+    'image_upload_error' => 'Įvyko klaida įkeliant vaizdą',
+    'image_upload_type_error' => 'Vaizdo tipas, kurį norima įkelti, yra neteisingas',
+    'file_upload_timeout' => 'Failo įkėlimo laikas baigėsi',
+
+    // Attachments
+    'attachment_not_found' => 'Priedas nerastas',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Juodraščio išsaugoti nepavyko. Įsitikinkite, jog turite interneto ryšį prieš išsaugant šį paslapį.',
+    'page_custom_home_deletion' => 'Negalima ištrinti šio puslapio, kol jis yra nustatytas kaip pagrindinis puslapis',
+
+    // Entities
+    'entity_not_found' => 'Subjektas nerastas',
+    'bookshelf_not_found' => 'Knygų lentyna nerasta',
+    'book_not_found' => 'Knyga nerasta',
+    'page_not_found' => 'Puslapis nerastas',
+    'chapter_not_found' => 'Skyrius nerastas',
+    'selected_book_not_found' => 'Pasirinkta knyga nerasta',
+    'selected_book_chapter_not_found' => 'Pasirinkta knyga ar skyrius buvo nerasti',
+    'guests_cannot_save_drafts' => 'Svečiai negali išsaugoti juodraščių',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Negalite ištrinti vienintelio administratoriaus',
+    'users_cannot_delete_guest' => 'Negalite ištrinti svečio naudotojo',
+
+    // Roles
+    'role_cannot_be_edited' => 'Šio vaidmens negalima redaguoti',
+    'role_system_cannot_be_deleted' => 'Šis vaidmuo yra sistemos vaidmuo ir jo negalima ištrinti',
+    'role_registration_default_cannot_delete' => 'Šis vaidmuo negali būti ištrintas, kai yra nustatytas kaip numatytasis registracijos vaidmuo',
+    'role_cannot_remove_only_admin' => 'Šis naudotojas yra vienintelis naudotojas, kuriam yra paskirtas administratoriaus vaidmuo. Paskirkite administratoriaus vaidmenį kitam naudotojui prieš bandant jį pašalinti.',
+
+    // Comments
+    'comment_list' => 'Gaunant komentarus įvyko klaida.',
+    'cannot_add_comment_to_draft' => 'Negalite pridėti komentaro juodraštyje',
+    'comment_add' => 'Klaido įvyko pridedant/atnaujinant komantarą.',
+    'comment_delete' => 'Trinant komentarą įvyko klaida.',
+    'empty_comment' => 'Negalite pridėti tuščio komentaro.',
+
+    // Error pages
+    '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_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',
+    'error_occurred' => 'Įvyko klaida',
+    'app_down' => ':appName dabar yra apačioje',
+    'back_soon' => 'Tai sugrįž greitai',
+
+    // API errors
+    'api_no_authorization_found' => 'Užklausoje nerastas įgaliojimo prieigos raktas',
+    'api_bad_authorization_format' => 'Užklausoje rastas prieigos raktas, tačiau formatas yra neteisingas',
+    'api_user_token_not_found' => 'Pateiktam prieigos raktui nebuvo rastas atitinkamas API prieigos raktas',
+    'api_incorrect_token_secret' => 'Pateiktas panaudoto API žetono slėpinys yra neteisingas',
+    'api_user_no_api_permission' => 'API prieigos rakto savininkas neturi leidimo daryti API skambučius',
+    'api_user_token_expired' => 'Prieigos rakto naudojimas baigė galioti',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Siunčiant bandymo email: įvyko klaida',
+
+];
diff --git a/resources/lang/lt/pagination.php b/resources/lang/lt/pagination.php
new file mode 100644 (file)
index 0000000..f962f12
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Pagination Language Lines
+ * The following language lines are used by the paginator library to build
+ * the simple pagination links.
+ */
+return [
+
+    'previous' => '&laquo; Ankstesnis',
+    'next'     => 'Kitas &raquo;',
+
+];
diff --git a/resources/lang/lt/passwords.php b/resources/lang/lt/passwords.php
new file mode 100644 (file)
index 0000000..672620d
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Password Reminder Language Lines
+ * The following language lines are the default lines which match reasons
+ * that are given by the password broker for a password update attempt has failed.
+ */
+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.",
+    '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ą!',
+    'reset' => 'Jūsų slaptažodis buvo atkurtas!',
+
+];
diff --git a/resources/lang/lt/settings.php b/resources/lang/lt/settings.php
new file mode 100644 (file)
index 0000000..37904a2
--- /dev/null
@@ -0,0 +1,306 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Nustatymai',
+    'settings_save' => 'Išsaugoti nustatymus',
+    'settings_save_success' => 'Nustatymai išsaugoti',
+
+    // App Settings
+    'app_customization' => 'Tinkinimas',
+    'app_features_security' => 'Funkcijos ir sauga',
+    'app_name' => 'Programos pavadinimas',
+    'app_name_desc' => 'Šis pavadinimas yra rodomas antraštėje ir bet kuriuose sistemos siunčiamuose elektroniniuose laiškuose.',
+    'app_name_header' => 'Rodyti pavadinimą antraštėje',
+    'app_public_access' => 'Vieša prieiga',
+    'app_public_access_desc' => 'Įjungus šią parinktį lankytojai, kurie nėra prisijungę, galės pasiekti BookStack egzemplioriaus turinį.',
+    'app_public_access_desc_guest' => 'Prieiga viešiems lankytojams gali būti kontroliuojama per "Svečio" naudotoją.',
+    'app_public_access_toggle' => 'Leisti viešą prieigą',
+    'app_public_viewing' => 'Leisti viešą žiūrėjimą?',
+    'app_secure_images' => 'Didesnio saugumo vaizdų įkėlimai',
+    'app_secure_images_toggle' => 'Įgalinti didesnio saugumo vaizdų įkėlimus',
+    'app_secure_images_desc' => 'Dėl veiklos priežasčių, visi vaizdai yra vieši. Šis pasirinkimas prideda atsitiktinę, sunkiai atspėjamą eilutę prieš vaizdo URL. Įsitikinkite, kad katalogų rodyklės neįgalintos, kad prieiga būtų lengvesnė.',
+    'app_editor' => 'Puslapio redaktorius',
+    'app_editor_desc' => 'Pasirinkite, kuris redaktorius bus naudojamas visų vartotojų redaguoti puslapiams.',
+    'app_custom_html' => 'Pasirinktinis HTL antraštės turinys',
+    'app_custom_html_desc' => 'Bet koks čia pridedamas turinys bus prisegamas apačioje <antraštės> kiekvieno puslapio skyriuje. Tai yra patogu svarbesniems stiliams arba pridedant analizės kodą.',
+    'app_custom_html_disabled_notice' => 'Pasirinktinis HTML antraštės turinys yra išjungtas šiame nustatymų puslapyje užtikrinti, kad bet kokie negeri pokyčiai galėtų būti anuliuojami.',
+    'app_logo' => 'Programos logotipas',
+    'app_logo_desc' => 'Šis vaizdas turėtų būti 43px aukščio. <br> Dideli vaizdai bus sumažinti.',
+    'app_primary_color' => 'Programos pagrindinė spalva',
+    'app_primary_color_desc' => 'Nustato pagrindinę spalvą programai, įskaitant reklamjuostę, mygtukus ir nuorodas.',
+    'app_homepage' => 'Programos pagrindinis puslapis',
+    'app_homepage_desc' => 'Pasirinkite vaizdą rodyti pagrindiniame paslapyje vietoj numatyto vaizdo. Puslapio leidimai yra ignoruojami pasirinktiems puslapiams.',
+    'app_homepage_select' => 'Pasirinkti puslapį',
+    'app_footer_links' => 'Poraštės nuorodos',
+    'app_footer_links_desc' => 'Pridėkite nuorodas, kurias norite pridėti svetainės poraštėje. Jos bus rodomos daugelio puslapių apačioje, įskaitant ir tuos, kurie nereikalauja prisijungimo. Jūs galite naudoti etiktę "trans::<key>", kad naudotis sistemos apibrėžtais vertimais. Pavyzdžiui: naudojimasis "trans::common.privacy_policy" bus pateiktas išverstu tekstu "Privatumo Politika" ir ""trans::common.terms_of_service" bus pateikta išverstu tekstu "Paslaugų Teikimo Sąlygos".',
+    'app_footer_links_label' => 'Etiketės nuoroda',
+    'app_footer_links_url' => 'Nuoroda URL',
+    'app_footer_links_add' => 'Pridėti poraštes nuorodą',
+    'app_disable_comments' => 'Išjungti komentarus',
+    'app_disable_comments_toggle' => 'Išjungti komentarus',
+    'app_disable_comments_desc' => 'Išjungti komentarus visuose programos puslapiuose. <br> Esantys komentarai nerodomi.',
+
+    // Color settings
+    'content_colors' => 'Turinio spalvos',
+    'content_colors_desc' => 'Nustato spalvas visiems elementams puslapio organizacijos herarchijoje. Rekomenduojama pasirinkti spalvas su panačiu šviesumu kaip numatytos spalvos, kad būtų lengviau skaityti.',
+    'bookshelf_color' => 'Lentynos spalva',
+    'book_color' => 'Knygos spalva',
+    'chapter_color' => 'Skyriaus spalva',
+    'page_color' => 'Puslapio spalva',
+    'page_draft_color' => 'Puslapio juodraščio spalva',
+
+    // Registration Settings
+    'reg_settings' => 'Registracija',
+    'reg_enable' => 'Įgalinti registraciją',
+    'reg_enable_toggle' => 'Įgalinti registraciją',
+    'reg_enable_desc' => 'Kai registracija yra įgalinta, naudotojai gali prisiregistruoti kaip programos naudotojai. Registruojantis jiems suteikiamas vienintelis, nematytasis naudotojo vaidmuo.',
+    'reg_default_role' => 'Numatytasis naudotojo vaidmuo po registracijos',
+    'reg_enable_external_warning' => 'Ankstesnė parinktis nepaisoma, kai išorinis LDAP arba SAML autentifikavimas yra aktyvus. Vartotojo paskyra neegzistuojantiems nariams bus automatiškai sukurta, jei autentifikavimas naudojant naudojamą išorinę sistemą bus sėkmingas.',
+    'reg_email_confirmation' => 'Elektroninio pašto patvirtinimas',
+    'reg_email_confirmation_toggle' => 'Reikalauja elektroninio pašto patvirtinimo',
+    'reg_confirm_email_desc' => 'Jei naudojamas domeno apribojimas, tada elektroninio pašto patvirtinimas bus reikalaujamas ir ši parinktis bus ignoruojama.',
+    'reg_confirm_restrict_domain' => 'Domeno apribojimas',
+    'reg_confirm_restrict_domain_desc' => 'Įveskite kableliais atskirtą elektroninio pašto domenų, kurių registravimą norite apriboti, sąrašą. Vartotojai išsiųs elektorinį laišką, kad patvirtintumėte jų adresą prieš leidžiant naudotis programa. <br> Prisiminkite, kad vartotojai galės pakeisti savo elektroninius paštus po sėkmingos registracijos.',
+    'reg_confirm_restrict_domain_placeholder' => 'Nėra jokių apribojimų',
+
+    // Maintenance settings
+    'maint' => 'Priežiūra',
+    'maint_image_cleanup' => 'Išvalykite vaizdus',
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
+    'maint_delete_images_only_in_revisions' => 'Taip pat ištrinkite vaizdus, kurie yra tik senuose puslapių pataisymuose',
+    'maint_image_cleanup_run' => 'Paleisti valymą',
+    'maint_image_cleanup_warning' => ':count potencialiai nepanaudoti vaizdai rasti. Ar esate tikri, kad norite ištrinti šiuos vaizdus?',
+    'maint_image_cleanup_success' => ':count potencialiai nepanaudoti vaizdai rasti ir ištrinti!',
+    'maint_image_cleanup_nothing_found' => 'Nerasta nepanaudotų vaizdų, niekas neištrinta!',
+    'maint_send_test_email' => 'Siųsti bandomąjį elektroninį laišką',
+    'maint_send_test_email_desc' => 'ai siunčia bandomąjį elektroninį laišką elektroninio pašto adresu, nurodytu jūsų profilyje.',
+    'maint_send_test_email_run' => 'Siųsti bandomąjį elektroninį laišką',
+    'maint_send_test_email_success' => 'Elektroninis laiškas išsiųstas :address',
+    'maint_send_test_email_mail_subject' => 'Bandomasis elektroninis laiškas',
+    'maint_send_test_email_mail_greeting' => 'Elektroninio laiško pristatymas veikia!',
+    'maint_send_test_email_mail_text' => 'Sveikiname! Kadangi gavote šį elektroninio pašto pranešimą, jūsų elektroninio pašto nustatymai buvo sukonfigūruoti teisingai.',
+    'maint_recycle_bin_desc' => 'Ištrintos lentynos, knygos, skyriai ir puslapiai yra perkeliami į šiukšliadėžę tam, kad jie galėtų būti atkurti arba ištrinti visam laikui. Senesni elementai, esantys šiukšliadėžėje, gali būti automatiškai panaikinti po tam tikro laiko priklausomai nuo sistemos konfigūracijos.',
+    'maint_recycle_bin_open' => 'Atidaryti šiukšliadėžę',
+
+    // Recycle Bin
+    'recycle_bin' => 'Šiukšliadėžė',
+    'recycle_bin_desc' => 'Čia gali atkurti elementus, kurie buvo ištrinti arba pasirinkti pašalinti juos iš sistemos visam laikui. Šis sąrašas yra nefiltruotas kaip kitie panašus veiklos sąrašai sistemoje, kuriems yra taikomi leidimo filtrai.',
+    'recycle_bin_deleted_item' => 'Ištrintas elementas',
+    'recycle_bin_deleted_parent' => 'Parent',
+    'recycle_bin_deleted_by' => 'Ištrynė',
+    'recycle_bin_deleted_at' => 'Panaikinimo laikas',
+    'recycle_bin_permanently_delete' => 'Ištrinti visam laikui',
+    'recycle_bin_restore' => 'Atkurti',
+    'recycle_bin_contents_empty' => 'Šiukšliadėžė šiuo metu yra tuščia',
+    'recycle_bin_empty' => 'Ištuštinti šiukšliadėžę',
+    'recycle_bin_empty_confirm' => 'Tai visam laikui sunaikins visus elementus, esančius šiukšliadėžėje, įskaitant kiekvieno elemento turinį. Ar esate tikri, jog norite ištuštinti šiukšliadėžę?',
+    'recycle_bin_destroy_confirm' => 'Šis veiksmas visam laikui ištrins šį elementą iš sistemos kartu su bet kuriais elementais įvardintais žemiau ir jūs nebegalėsite atkurti jo bei jo turinio. Ar esate tikri, jog norite visam laikui ištrinti šį elementą?',
+    'recycle_bin_destroy_list' => 'Elementai panaikinimui',
+    'recycle_bin_restore_list' => 'Elementai atkūrimui',
+    'recycle_bin_restore_confirm' => 'Šis veiksmas atkurs ištrintą elementą ir perkels jį atgal į jo originalią vietą. Jei originali vieta buvo ištrinta ir šiuo metu yra šiukšliadėžėje, ji taip pat turės būti atkurta.',
+    'recycle_bin_restore_deleted_parent' => 'Pagrindinis elementas buvo ištrintas. Šie elementai liks ištrinti iki tol, kol bus atkurtas pagrindinis elementas.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'Ištrinti :count visus elementus, esančius šiukšliadėžėje.',
+    'recycle_bin_restore_notification' => 'Atkurti :count visus elementus, esančius šiukšliadėžėje.',
+
+    // Audit Log
+    'audit' => 'Audito seka',
+    'audit_desc' => 'Ši audito seka rodo sąrašą veiklų, rastų sistemoje. Šis sąrašas yra nefiltruotas kaip kitie panašus veiklos sąrašai sistemoje, kuriems yra taikomi leidimo filtrai.',
+    'audit_event_filter' => 'Įvykio filtras',
+    'audit_event_filter_no_filter' => 'Be filtrų',
+    'audit_deleted_item' => 'Ištrintas elementas',
+    'audit_deleted_item_name' => 'Vardas: :name',
+    'audit_table_user' => 'Naudotojas',
+    'audit_table_event' => 'Įvykis',
+    'audit_table_related' => 'Susijęs elementas arba detalė',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Veiklos data',
+    'audit_date_from' => 'Datos seka nuo',
+    'audit_date_to' => 'Datos seka iki',
+
+    // Role Settings
+    'roles' => 'Vaidmenys',
+    'role_user_roles' => 'Naudotojo vaidmenys',
+    'role_create' => 'Sukurti naują vaidmenį',
+    'role_create_success' => 'Vaidmuo sukurtas sėkmingai',
+    'role_delete' => 'Ištrinti vaidmenį',
+    'role_delete_confirm' => 'Tai ištrins vaidmenį vardu\':roleName\'.',
+    'role_delete_users_assigned' => 'Šis vaidmuo turi :userCount naudotojus priskirtus prie jo. Jeigu norite naudotojus perkelti iš šio vaidmens, pasirinkite naują vaidmenį apačioje.',
+    'role_delete_no_migration' => "Don't migrate users",
+    'role_delete_sure' => 'Ar esate tikri, jog norite ištrinti šį vaidmenį?',
+    'role_delete_success' => 'Vaidmuo ištrintas sėkmingai',
+    'role_edit' => 'Redaguoti vaidmenį',
+    'role_details' => 'Vaidmens detalės',
+    'role_name' => 'Vaidmens pavadinimas',
+    'role_desc' => 'Trumpas vaidmens aprašymas',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_external_auth_id' => 'Išorinio autentifikavimo ID',
+    'role_system' => 'Sistemos leidimai',
+    'role_manage_users' => 'Tvarkyti naudotojus',
+    'role_manage_roles' => 'Tvarkyti vaidmenis ir vaidmenų leidimus',
+    'role_manage_entity_permissions' => 'Tvarkyti visus knygų, skyrių ir puslapių leidimus',
+    'role_manage_own_entity_permissions' => 'Tvarkyti savo knygos, skyriaus ir puslapių leidimus',
+    'role_manage_page_templates' => 'Tvarkyti puslapių šablonus',
+    'role_access_api' => 'Gauti prieigą prie sistemos API',
+    'role_manage_settings' => 'Tvarkyti programos nustatymus',
+    'role_export_content' => 'Export content',
+    '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_all' => 'Visi',
+    'role_own' => 'Nuosavi',
+    'role_controlled_by_asset' => 'Kontroliuojami nuosavybės, į kurią yra įkelti',
+    'role_save' => 'Išsaugoti vaidmenį',
+    'role_update_success' => 'Vaidmuo atnaujintas sėkmingai',
+    'role_users' => 'Naudotojai šiame vaidmenyje',
+    'role_users_none' => 'Šiuo metu prie šio vaidmens nėra priskirta naudotojų',
+
+    // Users
+    'users' => 'Naudotojai',
+    'user_profile' => 'Naudotojo profilis',
+    'users_add_new' => 'Pridėti naują naudotoją',
+    'users_search' => 'Ieškoti naudotojų',
+    'users_latest_activity' => 'Naujausia veikla',
+    'users_details' => 'Naudotojo detalės',
+    'users_details_desc' => 'Nustatykite rodomąjį vardą ir elektroninio pašto adresą šiam naudotojui. Šis elektroninio pašto adresas bus naudojamas prisijungimui prie aplikacijos.',
+    'users_details_desc_no_email' => 'Nustatykite rodomąjį vardą šiam naudotojui, kad kiti galėtų jį atpažinti.',
+    'users_role' => 'Naudotojo vaidmenys',
+    'users_role_desc' => 'Pasirinkite, prie kokių vaidmenų bus priskirtas šis naudotojas. Jeigu naudotojas yra priskirtas prie kelių vaidmenų, leidimai iš tų vaidmenų susidės ir jie gaus visus priskirtų vaidmenų gebėjimus.',
+    'users_password' => 'Naudotojo slaptažodis',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
+    'users_send_invite_text' => 'Jūs galite pasirinkti nusiųsti šiam naudotojui kvietimą elektroniniu paštu, kuris leistų jiems patiems susikurti slaptažodį. Priešingu atveju slaptažodį galite sukurti patys.',
+    'users_send_invite_option' => 'Nusiųsti naudotojui kvietimą elektroniniu paštu',
+    'users_external_auth_id' => 'Išorinio autentifikavimo ID',
+    'users_external_auth_id_desc' => 'Tai yra ID, naudojamas norint suderinti šį naudotoją bendraujant su jūsų išorinio autentifikavimo sistema.',
+    'users_password_warning' => 'Užpildykite laukelį apačioje tik tuo atveju, jeigu norite pakeisti savo slaptažodį.',
+    'users_system_public' => 'Šis naudotojas atstovauja svečius, kurie aplanko jūsų egzempliorių. Jis negali būti naudojamas prisijungimui, tačiau yra priskiriamas automatiškai.',
+    'users_delete' => 'Ištrinti naudotoją',
+    'users_delete_named' => 'Ištrinti naudotoją :userName',
+    'users_delete_warning' => 'Tai pilnai ištrins šį naudotoją vardu \':userName\' iš sistemos.',
+    'users_delete_confirm' => 'Ar esate tikri, jog norite ištrinti šį naudotoją?',
+    'users_migrate_ownership' => 'Perkelti nuosavybę',
+    'users_migrate_ownership_desc' => 'Pasirinkite naudotoją, jeigu norite, kad kitas naudotojas taptų visų elementų, šiuo metu priklausančių šiam naudotojui, savininku.',
+    'users_none_selected' => 'Naudotojas nepasirinktas',
+    'users_delete_success' => 'Naudotojas sėkmingai pašalintas',
+    'users_edit' => 'Redaguoti naudotoją',
+    'users_edit_profile' => 'Redaguoti profilį',
+    'users_edit_success' => 'Naudotojas sėkmingai atnaujintas',
+    'users_avatar' => 'Naudotojo pseudoportretas',
+    'users_avatar_desc' => 'Pasirinkite nuotrauką, pavaizduojančią šį naudotoją. Nuotrauka turi būti maždaug 256px kvadratas.',
+    'users_preferred_language' => 'Norima kalba',
+    'users_preferred_language_desc' => 'Ši parinktis pakeis kalbą, naudojamą naudotojo sąsajoje aplikacijoje. Tai neturės įtakos jokiam vartotojo sukurtam turiniui.',
+    'users_social_accounts' => 'Socialinės paskyros',
+    'users_social_accounts_info' => 'Čia galite susieti savo kitas paskyras greitesniam ir lengvesniam prisijungimui. Atjungus paskyrą čia neatšaukiama anksčiau leista prieiga. Atšaukite prieigą iš profilio nustatymų prijungtoje socialinėje paskyroje.',
+    'users_social_connect' => 'Susieti paskyrą',
+    'users_social_disconnect' => 'Atskirti paskyrą',
+    'users_social_connected' => ':socialAccount paskyra buvo sėkmingai susieta su jūsų profiliu.',
+    'users_social_disconnected' => ':socialAccount paskyra buvo sėkmingai atskirta nuo jūsu profilio.',
+    'users_api_tokens' => 'API sąsajos prieigos raktai',
+    'users_api_tokens_none' => 'Jokie API sąsajos prieigos raktai nebuvo sukurti šiam naudotojui',
+    'users_api_tokens_create' => 'Sukurti prieigos raktą',
+    'users_api_tokens_expires' => 'Baigia galioti',
+    'users_api_tokens_docs' => 'API dokumentacija',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
+
+    // API Tokens
+    'user_api_token_create' => 'Sukurti API sąsajos prieigos raktą',
+    'user_api_token_name' => 'Pavadinimas',
+    'user_api_token_name_desc' => 'Suteikite savo prieigos raktui perskaitomą pavadinimą kaip priminimą ateičiai apie jo numatytą tikslą.',
+    'user_api_token_expiry' => 'Galiojimo laikas',
+    'user_api_token_expiry_desc' => 'Nustatykite datą kada šis prieigos raktas baigs galioti. Po šios datos, prašymai, atlikti naudojant šį prieigos raktą daugiau nebeveiks. Jeigu šį laukelį paliksite tuščią, galiojimo laikas bus nustatytas 100 metų į ateitį.',
+    'user_api_token_create_secret_message' => 'Iš karto sukūrus šį prieigos raktą, bus sukurtas ir rodomas "Priegos rakto ID" ir "Prieigos rakto slėpinys". Prieigos rakto slėpinys bus rodomas tik vieną kartą, todėl būtinai nukopijuokite jį kur nors saugioje vietoje.',
+    'user_api_token_create_success' => 'API sąsajos prieigos raktas sėkmingai sukurtas',
+    'user_api_token_update_success' => 'API sąsajos prieigos raktas sėkmingai atnaujintas',
+    'user_api_token' => 'API sąsajos prieigos raktas',
+    'user_api_token_id' => 'Prieigos rakto ID',
+    'user_api_token_id_desc' => 'Tai neredaguojamas sistemos sugeneruotas identifikatorius šiam prieigos raktui, kurį reikės pateikti API užklausose.',
+    'user_api_token_secret' => 'Priegos rakto slėpinys',
+    'user_api_token_secret_desc' => 'Tai yra sistemos sukurtas šio priegos rakto slėpinys, kurią reikės pateikti API užklausose. Tai bus rodoma tik šį kartą, todėl nukopijuokite šią vertę į saugią vietą.',
+    'user_api_token_created' => 'Prieigos raktas sukurtas :timeAgo',
+    'user_api_token_updated' => 'Prieigos raktas atnaujintas :timeAgo',
+    'user_api_token_delete' => 'Ištrinti prieigos raktą',
+    'user_api_token_delete_warning' => 'Tai pilnai ištrins šį API sąsajos prieigos raktą pavadinimu \':tokenName\' iš sistemos.',
+    'user_api_token_delete_confirm' => 'Ar esate tikri, jog norite ištrinti šį API sąsajos prieigos raktą?',
+    'user_api_token_delete_success' => 'API sąsajos prieigos raktas sėkmingai ištrintas',
+
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
+    //! If editing translations files directly please ignore this in all
+    //! languages apart from en. Content will be auto-copied from en.
+    //!////////////////////////////////
+    'language_select' => [
+        'en' => 'English',
+        'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
+        'cs' => 'Česky',
+        'da' => 'Dansk',
+        'de' => 'Deutsch (Sie)',
+        'de_informal' => 'Deutsch (Du)',
+        'es' => 'Español',
+        'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
+        'fr' => 'Français',
+        'he' => 'עברית',
+        'hr' => 'Hrvatski',
+        'hu' => 'Magyar',
+        'id' => 'Bahasa Indonesia',
+        'it' => 'Italian',
+        'ja' => '日本語',
+        'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
+        'lv' => 'Latviešu Valoda',
+        'nl' => 'Nederlands',
+        'nb' => 'Norsk (Bokmål)',
+        'pl' => 'Polski',
+        'pt' => 'Português',
+        'pt_BR' => 'Português do Brasil',
+        'ru' => 'Русский',
+        'sk' => 'Slovensky',
+        'sl' => 'Slovenščina',
+        'sv' => 'Svenska',
+        'tr' => 'Türkçe',
+        'uk' => 'Українська',
+        'vi' => 'Tiếng Việt',
+        'zh_CN' => '简体中文',
+        'zh_TW' => '繁體中文',
+    ],
+    //!////////////////////////////////
+];
diff --git a/resources/lang/lt/validation.php b/resources/lang/lt/validation.php
new file mode 100644 (file)
index 0000000..8fa9234
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+/**
+ * Validation Lines
+ * The following language lines contain the default error messages used by
+ * the validator class. Some of these rules have multiple versions such
+ * as the size rules. Feel free to tweak each of these messages here.
+ */
+return [
+
+    // Standard laravel validation lines
+    'accepted'             => ':attribute turi būti priimtas.',
+    'active_url'           => ':attribute nėra tinkamas URL.',
+    'after'                => ':attribute turi būti data po :date.',
+    'alpha'                => ':attribute turi būti sudarytis tik iš raidžių.',
+    'alpha_dash'           => ':attribute turi būti sudarytas tik iš raidžių, skaičių, brūkšnelių ir pabraukimų.',
+    'alpha_num'            => ':attribute turi būti sudarytas tik iš raidžių ir skaičių.',
+    'array'                => ':attribute turi būti masyvas.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
+    'before'               => ':attribute turi būti data anksčiau negu :date.',
+    'between'              => [
+        'numeric' => ':attribute turi būti tarp :min ir :max.',
+        'file'    => ':attribute turi būti tarp :min ir :max kilobaitų.',
+        'string'  => ':attribute turi būti tarp :min ir :max simbolių.',
+        'array'   => ':attribute turi turėti tarp :min ir :max elementų.',
+    ],
+    'boolean'              => ':attribute laukas turi būti tiesa arba melas.',
+    'confirmed'            => ':attribute patvirtinimas nesutampa.',
+    'date'                 => ':attribute nėra tinkama data.',
+    'date_format'          => ':attribute neatitinka formato :format.',
+    'different'            => ':attribute ir :other turi būti skirtingi.',
+    'digits'               => ':attribute turi būti :digits skaitmenų.',
+    'digits_between'       => ':attribute turi būti tarp :min ir :max skaitmenų.',
+    'email'                => ':attribute turi būti tinkamas elektroninio pašto adresas.',
+    'ends_with' => ':attribute turi pasibaigti vienu iš šių: :values',
+    'filled'               => ':attribute laukas yra privalomas.',
+    'gt'                   => [
+        'numeric' => ':attribute turi būti didesnis negu :value.',
+        'file'    => ':attribute turi būti didesnis negu :value kilobaitai.',
+        'string'  => ':attribute turi būti didesnis negu :value simboliai.',
+        'array'   => ':attribute turi turėti daugiau negu :value elementus.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute turi būti didesnis negu arba lygus :value.',
+        'file'    => ':attribute turi būti didesnis negu arba lygus :value kilobaitams.',
+        'string'  => ':attribute turi būti didesnis negu arba lygus :value simboliams.',
+        'array'   => ':attribute turi turėti :value elementus arba daugiau.',
+    ],
+    'exists'               => 'Pasirinktas :attribute yra klaidingas.',
+    'image'                => ':attribute turi būti paveikslėlis.',
+    'image_extension'      => ':attribute turi būti tinkamas ir palaikomas vaizdo plėtinys.',
+    'in'                   => 'Pasirinktas :attribute yra klaidingas.',
+    'integer'              => ':attribute turi būti sveikasis skaičius.',
+    'ip'                   => ':attribute turi būti tinkamas IP adresas.',
+    'ipv4'                 => ':attribute turi būti tinkamas IPv4 adresas.',
+    'ipv6'                 => ':attribute turi būti tinkamas IPv6 adresas.',
+    'json'                 => ':attribute turi būti tinkama JSON eilutė.',
+    'lt'                   => [
+        'numeric' => ':attribute turi būti mažiau negu :value.',
+        'file'    => ':attribute turi būti mažiau negu :value kilobaitai.',
+        'string'  => ':attribute turi būti mažiau negu :value simboliai.',
+        'array'   => ':attribute turi turėti mažiau negu :value elementus.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute turi būti mažiau arba lygus :value.',
+        'file'    => ':attribute turi būti mažiau arba lygus :value kilobaitams.',
+        'string'  => ':attribute turi būti mažiau arba lygus :value simboliams.',
+        'array'   => ':attribute negali turėti daugiau negu :value elementų.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute negali būti didesnis negu :max.',
+        'file'    => ':attribute negali būti didesnis negu :max kilobaitai.',
+        'string'  => ':attribute negali būti didesnis negu :max simboliai.',
+        'array'   => ':attribute negali turėti daugiau negu :max elementų.',
+    ],
+    'mimes'                => ':attribute turi būti tipo failas: :values.',
+    'min'                  => [
+        'numeric' => ':attribute turi būti mažiausiai :min.',
+        'file'    => ':attribute turi būti mažiausiai :min kilobaitų.',
+        'string'  => ':attribute turi būti mažiausiai :min simbolių.',
+        'array'   => ':attribute turi turėti mažiausiai :min elementus.',
+    ],
+    'not_in'               => 'Pasirinktas :attribute yra klaidingas.',
+    'not_regex'            => ':attribute formatas yra klaidingas.',
+    'numeric'              => ':attribute turi būti skaičius.',
+    'regex'                => ':attribute formatas yra klaidingas.',
+    'required'             => ':attribute laukas yra privalomas.',
+    'required_if'          => ':attribute laukas yra privalomas kai :other yra :value.',
+    'required_with'        => ':attribute laukas yra privalomas kai :values yra.',
+    'required_with_all'    => ':attribute laukas yra privalomas kai :values yra.',
+    'required_without'     => ':attribute laukas yra privalomas kai nėra :values.',
+    'required_without_all' => ':attribute laukas yra privalomas kai nėra nei vienos :values.',
+    'same'                 => ':attribute ir :other turi sutapti.',
+    'safe_url'             => 'Pateikta nuoroda gali būti nesaugi.',
+    'size'                 => [
+        'numeric' => ':attribute turi būti :size.',
+        'file'    => ':attribute turi būti :size kilobaitų.',
+        'string'  => ':attribute turi būti :size simbolių.',
+        'array'   => ':attribute turi turėti :size elementus.',
+    ],
+    'string'               => ':attribute turi būti eilutė.',
+    'timezone'             => ':attribute turi būti tinkama zona.',
+    'totp'                 => 'The provided code is not valid or has expired.',
+    'unique'               => ':attribute jau yra paimtas.',
+    'url'                  => ':attribute formatas yra klaidingas.',
+    'uploaded'             => 'Šis failas negali būti įkeltas. Serveris gali nepriimti tokio dydžio failų.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Reikalingas slaptažodžio patvirtinimas',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index 8f99e0ff6d261dd349b6ad45de65d444d29a89f5..6d152d23fa0f2aac49a0bebdc1d13b20ea223002 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'izveidoja lapu',
-    'page_create_notification'    => 'Lapa Veiksmīgi Izveidota',
+    'page_create_notification'    => 'Lapa veiksmīgi izveidota',
     'page_update'                 => 'atjaunoja lapu',
-    'page_update_notification'    => 'Lapa Veiksmīgi Atjaunota',
+    'page_update_notification'    => 'Lapa veiksmīgi atjaunināta',
     'page_delete'                 => 'izdzēsa lapu',
-    'page_delete_notification'    => 'Lapa Veiksmīgi Dzēsta',
+    'page_delete_notification'    => 'Lapa veiksmīgi dzēsta',
     'page_restore'                => 'atjaunoja lapu',
-    'page_restore_notification'   => 'Lapa Veiksmīgi Atjaunota',
+    'page_restore_notification'   => 'Lapa veiksmīgi atjaunota',
     'page_move'                   => 'pārvietoja lapu',
 
     // Chapters
     'chapter_create'              => 'izveidoja nodaļu',
-    'chapter_create_notification' => 'Nodaļa Veiksmīgi Izveidota',
+    'chapter_create_notification' => 'Nodaļa veiksmīgi izveidota',
     'chapter_update'              => 'atjaunoja nodaļu',
-    'chapter_update_notification' => 'Nodaļa Veiksmīgi Atjaunota',
+    'chapter_update_notification' => 'Nodaļa veiksmīgi atjaunināta',
     'chapter_delete'              => 'izdzēsa nodaļu',
-    'chapter_delete_notification' => 'Nodaļa Veiksmīgi Dzēsta',
+    'chapter_delete_notification' => 'Nodaļa veiksmīgi dzēsta',
     'chapter_move'                => 'pārvietoja nodaļu',
 
     // Books
     'book_create'                 => 'izveidoja grāmatu',
-    'book_create_notification'    => 'Grāmata Veiksmīgi Izveidota',
+    'book_create_notification'    => 'Grāmata veiksmīgi izveidota',
     'book_update'                 => 'atjaunoja grāmatu',
-    'book_update_notification'    => 'Grāmata Veiksmīgi Atjaunota',
+    'book_update_notification'    => 'Grāmata veiksmīgi atjaunināta',
     'book_delete'                 => 'izdzēsa grāmatu',
-    'book_delete_notification'    => 'Grāmata Veiksmīgi Dzēsta',
+    'book_delete_notification'    => 'Grāmata veiksmīgi dzēsta',
     'book_sort'                   => 'kārtoja grāmatu',
-    'book_sort_notification'      => 'Grāmata Veiksmīgi Pārkārtota',
+    'book_sort_notification'      => 'Grāmata veiksmīgi pārkārtota',
 
     // Bookshelves
-    'bookshelf_create'            => 'izveidoja Plauktu',
-    'bookshelf_create_notification'    => 'Plaukts Veiksmīgi Izveidots',
+    'bookshelf_create'            => 'izveidoja plautku',
+    'bookshelf_create_notification'    => 'Plaukts veiksmīgi izveidots',
     'bookshelf_update'                 => 'atjaunoja plauktu',
-    'bookshelf_update_notification'    => 'Plaukts Veiksmīgi Atjaunots',
+    'bookshelf_update_notification'    => 'Plaukts veiksmīgi atjaunināts',
     'bookshelf_delete'                 => 'izdzēsa plauktu',
-    'bookshelf_delete_notification'    => 'Plaukts Veiksmīgi Dzēsts',
+    'bookshelf_delete_notification'    => 'Plaukts veiksmīgi dzēsts',
+
+    // Favourites
+    'favourite_add_notification' => '":name" ir pievienots jūsu favorītiem',
+    'favourite_remove_notification' => '":name" ir izņemts no jūsu favorītiem',
+
+    // MFA
+    'mfa_setup_method_notification' => '2FA funkcija aktivizēta',
+    'mfa_remove_method_notification' => '2FA funkcija noņemta',
+
+    // Webhooks
+    'webhook_create' => 'izveidoja webhook',
+    'webhook_create_notification' => 'Webhook veiksmīgi izveidots',
+    'webhook_update' => 'atjaunināja webhook',
+    'webhook_update_notification' => 'Webhook veiksmīgi atjaunināts',
+    'webhook_delete' => 'izdzēsa webhook',
+    'webhook_delete_notification' => 'Webhook veiksmīgi izdzēsts',
 
     // Other
     'commented_on'                => 'komentēts',
index dc84a2d978040d24c332247fefb5b7d78bdf0cfe..fd8e2e9c681f0f12be806a80206e1da712543f7a 100644 (file)
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'E-pasta domēnam nav piekļuves pie šīs aplikācijas',
     'register_success' => 'Paldies par reģistrēšanos! Tagad varat pieslēgties.',
 
-
     // Password Reset
     'reset_password' => 'Atiestatīt paroli',
     'reset_password_send_instructions' => 'Ievadiet savu e-pastu zemāk un nosūtīsim e-pastu ar paroles atiestatīšanas saiti.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Jūs saņemat šo e-pastu, jo mēs saņēmām Jūsu profila paroles atiestatīšanas pieprasījumu.',
     'email_reset_not_requested' => 'Ja Jūs nepieprasījāt paroles atiestatīšanu, tad tālākas darbības nav nepieciešamas.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Apstiprinat savu :appName e-pastu',
     'email_confirm_greeting' => 'Paldies, ka pievienojāties :appName!',
     'email_confirm_text' => 'Lūdzu apstipriniet savu e-pastu nospiežot zemāk redzamo pogu:',
     'email_confirm_action' => 'Apstiprināt e-pastu',
     'email_confirm_send_error' => 'E-pasta apriprināšana ir nepieciešama, bet sistēma nevarēja e-pastu nosūtīt. Lūdzu sazinaties ar administratoru, lai pārliecinātos, ka e-pasts ir iestatīts pareizi.',
-    'email_confirm_success' => 'Jūsu e-pasts ir apstiprināts!',
+    'email_confirm_success' => 'Jūsu epasta adrese ir apstiprināta! Jums tagad jābūt iespējai pieslēgties, izmantojot šo epasta adresi.',
     'email_confirm_resent' => 'Apstiprinājuma vēstule tika nosūtīta. Lūdzu, pārbaudiet jūsu e-pastu.',
 
     'email_not_confirmed' => 'E-pasts nav apstiprināts',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Sveicināti :appName!',
     'user_invite_page_text' => 'Lai pabeigtu profila izveidi un piekļūtu :appName ir jāizveido parole.',
     'user_invite_page_confirm_button' => 'Apstiprināt paroli',
-    'user_invite_success' => 'Parole iestatīta, tagad varat piekļūt :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Parole ir uzstādīta, jums tagad jābūt iespējai pieslēgties izmantojot uzstādīto paroli, lai piekļūtu :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Iestati divfaktoru autentifikāciju (2FA)',
+    'mfa_setup_desc' => 'Iestati divfaktoru autentifikāciju kā papildus drošību tavam lietotāja kontam.',
+    'mfa_setup_configured' => 'Divfaktoru autentifikācija jau ir nokonfigurēta',
+    'mfa_setup_reconfigure' => 'Mainīt 2FA konfigurāciju',
+    'mfa_setup_remove_confirmation' => 'Vai esi drošs, ka vēlies noņemt divfaktoru autentifikāciju?',
+    'mfa_setup_action' => 'Iestatījumi',
+    'mfa_backup_codes_usage_limit_warning' => 'Jums atlikuši mazāk kā 5 rezerves kodi. Lūdzu izveidojiet jaunu kodu komplektu pirms tie visi izlietoti, lai izvairītos no izslēgšanas no jūsu konta.',
+    'mfa_option_totp_title' => 'Mobilā aplikācija',
+    'mfa_option_totp_desc' => 'Lai lietotu vairākfaktoru autentifikāciju, jums būs nepieciešama mobilā aplikācija, kas atbalsta TOTP, piemēram, Google Authenticator, Authy vai Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Rezerves kodi',
+    'mfa_option_backup_codes_desc' => 'Droši noglabājiet vienreizlietojamu rezerves kodu komplektu, ko varēsiet izmantot, lai verificētu savu identitāti.',
+    'mfa_gen_confirm_and_enable' => 'Apstiprināt un ieslēgt',
+    'mfa_gen_backup_codes_title' => 'Rezerves kodu iestatījumi',
+    'mfa_gen_backup_codes_desc' => 'Noglabājiet zemāk esošo kodu sarakstu drošā vietā. Kad piekļūsiet sistēmai, jūs varēsiet izmantot vienu no kodiem kā papildus autentifikācijas mehānismu.',
+    'mfa_gen_backup_codes_download' => 'Lejupielādēt kodus',
+    'mfa_gen_backup_codes_usage_warning' => 'Katru kodu var izmantot tikai vienreiz',
+    'mfa_gen_totp_title' => 'Mobilās aplikācijas iestatījumi',
+    'mfa_gen_totp_desc' => 'Lai lietotu vairākfaktoru autentifikāciju, jums būs nepieciešama mobilā aplikācija, kas atbalsta TOTP, piemēram, Google Authenticator, Authy vai Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Skenējiet zemāk esošo kvadrātkodu (QR) izmantojot savu autentifikācijas aplikāciju.',
+    'mfa_gen_totp_verify_setup' => 'Verificēt iestatījumus',
+    'mfa_gen_totp_verify_setup_desc' => 'Pārbaudiet, ka viss darbojas, zemāk esošajā laukā ievadot kodu, ko izveidojusi jūsu autentifikācijas aplikācijā:',
+    'mfa_gen_totp_provide_code_here' => 'Norādīet jūsu aplikācijā izveidoto kodu šeit',
+    'mfa_verify_access' => 'Verificēt piekļuvi',
+    'mfa_verify_access_desc' => 'Jūsu lietotāja kontam nepieciešams verificēt jūsu identitāti ar papildus pārbaudes līmeni pirms piešķirta piekļuve. Verificējiet, izmantojot vienu no uzstādītajām metodēm, lai turpinātu.',
+    'mfa_verify_no_methods' => 'Nav iestatīta neviena metode',
+    'mfa_verify_no_methods_desc' => 'Jūsu kontam nav iestatīta neviena vairākfaktoru autentifikācijas metode. Jums būs nepieciešams iestatīt vismaz vienu metodi, lai iegūtu piekļuvi.',
+    'mfa_verify_use_totp' => 'Verificēt, izmantojot mobilo aplikāciju',
+    'mfa_verify_use_backup_codes' => 'Verificēt, izmantojot rezerves kodu',
+    'mfa_verify_backup_code' => 'Rezerves kods',
+    'mfa_verify_backup_code_desc' => 'Zemāk ievadiet vienu no jūsu atlikušajiem rezerves kodiem:',
+    'mfa_verify_backup_code_enter_here' => 'Ievadiet rezerves kodu šeit',
+    'mfa_verify_totp_desc' => 'Zemāk ievadiet kodu, kas izveidots mobilajā aplikācijā:',
+    'mfa_setup_login_notification' => 'Vairākfaktoru metode iestatīta, lūdzu pieslēdzieties atkal izmantojot iestatīto metodi.',
+];
index 00664318e1ce9c438108383220538307e355eb7d..3cc648906789f382aaef86ca69f4c70902b35baf 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Loma',
     'cover_image' => 'Vāka attēls',
     'cover_image_description' => 'Šim attēlam būtu jābūt aptuveni 440x250px.',
-    
+
     // Actions
     'actions' => 'Darbības',
     'view' => 'Skatīt',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Atiestatīt',
     'remove' => 'Noņemt',
     'add' => 'Pievienot',
+    'configure' => 'Mainīt konfigurāciju',
     'fullscreen' => 'Pilnekrāns',
+    'favourite' => 'Pievienot favorītiem',
+    'unfavourite' => 'Noņemt no favorītiem',
+    'next' => 'Nākamais',
+    'previous' => 'Iepriekšējais',
+    'filter_active' => 'Aktīvais filtrs:',
+    'filter_clear' => 'Notīrīt filtru',
 
     // Sort Options
     'sort_options' => 'Kārtošanas Opcijas',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Nav skatāmu darbību',
     'no_items' => 'Vienumi nav pieejami',
     'back_to_top' => 'Uz augšu',
+    'skip_to_main_content' => 'Pāriet uz saturu',
     'toggle_details' => 'Rādīt aprakstu',
     'toggle_thumbnails' => 'Iezīmēt sīkatēlus',
     'details' => 'Sīkāka informācija',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Saraksta Skats',
     'default' => 'Noklusējums',
     'breadcrumb' => 'Navigācija',
+    'status' => 'Statuss',
+    'status_active' => 'Aktīvs',
+    'status_inactive' => 'Neaktīvs',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Izvērst galvenes izvēlni',
index 2639e225f3b220c8c14d2f7f164a59cb9ff17a19..0ddb2b203747979053d038b7f713bf228f5e455b 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Attēli',
     'my_recent_drafts' => 'Mani melnraksti',
     'my_recently_viewed' => 'Mani nesen skatītie',
+    'my_most_viewed_favourites' => 'Mani biežāk skatītie favorīti',
+    'my_favourites' => 'Mani favorīti',
     'no_pages_viewed' => 'Neviena lapa vēl nav skatīta',
     'no_pages_recently_created' => 'Nav radīta neviena lapa',
     'no_pages_recently_updated' => 'Nav atjaunināta neviena lapa',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Pilna satura web fails',
     'export_pdf' => 'PDF fails',
     'export_text' => 'Vienkāršs teksta fails',
+    'export_md' => 'Markdown fails',
 
     // Permissions and restrictions
     'permissions' => 'Atļaujas',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Grāmatplaukta atļaujas',
     'shelves_permissions_updated' => 'Grāmatplaukta atļaujas atjauninātas',
     'shelves_permissions_active' => 'Grāmatplaukta atļaujas ir aktīvas',
+    'shelves_permissions_cascade_warning' => 'Grāmatu plauktu atļaujas netiek automātiski pārvietotas uz grāmatām. Tas ir tāpēc, ka grāmata var atrasties vairākos plauktos. Tomēr atļaujas var nokopēt uz plauktam pievienotajām grāmatām, izmantojot zemāk norādīto opciju.',
     'shelves_copy_permissions_to_books' => 'Kopēt grāmatplaukta atļaujas uz grāmatām',
     'shelves_copy_permissions' => 'Kopēt atļaujas',
     'shelves_copy_permissions_explain' => 'Šis piemēros pašreizējās grāmatplaukta piekļuves tiesības visām tajā esošajām grāmatām. Pirms ieslēgšanas pārliecinieties, ka ir saglabātas izmaiņas grāmatplaukta piekļuves tiesībām.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Nodaļas pēdējās',
     'books_sort_show_other' => 'Rādīt citas grāmatas',
     'books_sort_save' => 'Saglabāt jauno kārtību',
+    'books_copy' => 'Kopēt grāmatu',
+    'books_copy_success' => 'Grāmata veiksmīgi nokopēta',
 
     // Chapters
     'chapter' => 'Nodaļa',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Pārvietot nodaļu',
     'chapters_move_named' => 'Pārvietot nodaļu :chapterName',
     'chapter_move_success' => 'Nodaļa pārviedota uz :bookName',
+    'chapters_copy' => 'Kopēt nodaļu',
+    'chapters_copy_success' => 'Nodaļa veiksmīgi nokopēta',
     'chapters_permissions' => 'Nodaļas atļaujas',
     'chapters_empty' => 'Šajā nodaļā nav pievienotu lapu.',
     'chapters_permissions_active' => 'Nodaļas atļaujas ir aktīvas',
@@ -219,7 +227,7 @@ return [
     'pages_revisions_numbered_changes' => 'Revīzijas #:id izmaiņas',
     'pages_revisions_changelog' => 'Izmaiņu žurnāls',
     'pages_revisions_changes' => 'Izmaiņas',
-    'pages_revisions_current' => 'Tekošā versija',
+    'pages_revisions_current' => 'Pašreizējā versija',
     'pages_revisions_preview' => 'Priekšskatījums',
     'pages_revisions_restore' => 'Atjaunot',
     'pages_revisions_none' => 'Šai lapai nav revīziju',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Jauna lapa',
     'pages_editing_draft_notification' => 'Jūs pašlaik veicat izmaiņas melnrakstā, kurš pēdējo reizi ir saglabāts :timeDiff.',
     'pages_draft_edited_notification' => 'Šī lapa ir tikusi atjaunināta. Šo melnrakstu ieteicams atmest.',
+    'pages_draft_page_changed_since_creation' => 'Šī lapa ir izmainīta kopš šī uzmetuma izveidošanas. Ieteicams šo uzmetumu dzēst, lai netiktu pazaudētas veiktās izmaiņas.',
     'pages_draft_edit_active' => [
         'start_a' => ':count lietotāji pašlaik veic izmaiņas šajā lapā',
         'start_b' => ':userName veic izmaiņas šajā lapā',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Pievieno birkas, lai precīzāk grupētu saturu.\n Tu vari pievienot papildus vērtību birkai vēl precīzākai grupēšanai.",
     'tags_add' => 'Pievienot vēlvienu birku',
     'tags_remove' => 'Noņemt šo birku',
+    'tags_usages' => 'Kopējais birku lietojums',
+    'tags_assigned_pages' => 'Pievienotas lapām',
+    'tags_assigned_chapters' => 'Pievienotas nodaļām',
+    'tags_assigned_books' => 'Pievienotas grāmatām',
+    'tags_assigned_shelves' => 'Pievienotas plauktiem',
+    'tags_x_unique_values' => ':count unikālas vērtības',
+    'tags_all_values' => 'Visas vērtības',
+    'tags_view_tags' => 'Skatīt birkas',
+    'tags_view_existing_tags' => 'Skatīt esošās birkas',
+    'tags_list_empty_hint' => 'Birkas var pievienot lapas redaktora sānu kolonnā vai rediģējot grāmatas, nodaļas vai plaukta detaļas.',
     'attachments' => 'Pielikumi',
     'attachments_explain' => 'Augšupielādējiet dažus failus vai pievieno saites, kas tiks parādītas jūsu lapā. Tie būs redzami lapas sānjoslā.',
     'attachments_explain_instant_save' => 'Izmaiņas šeit tiek saglabātas nekavējoties.',
@@ -314,7 +333,15 @@ return [
 
     // Revision
     'revision_delete_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo revīziju?',
-    'revision_restore_confirm' => 'Vai esat pārliecināts, ka vēlaties atjaunot šo revīziju? Tekošais lapas saturs tiks aizstāts.',
+    'revision_restore_confirm' => 'Vai tiešām vēlaties atjaunot šo revīziju? Pašreizējais lapas saturs tiks aizvietots.',
     'revision_delete_success' => 'Revīzija dzēsta',
-    'revision_cannot_delete_latest' => 'Nevar dzēst tekošo revīziju.'
+    'revision_cannot_delete_latest' => 'Nevar dzēst pašreizējo revīziju.',
+
+    // Copy view
+    'copy_consider' => 'Kopējot saturu, lūdzu ņemiet vērā tālāk minēto.',
+    'copy_consider_permissions' => 'Pielāgoti tiesību uzstādījumi netiks nokopēti.',
+    'copy_consider_owner' => 'Jūs kļūsiet par visa kopētā satura īpašnieku.',
+    'copy_consider_images' => 'Lapas attēlu faili netiks kopēti un sākotnējie attēli saglabās savu saistību ar lapu, kurai tie tika sākotnēji pievienoti.',
+    'copy_consider_attachments' => 'Lapai pievienotie faili netiks nokopēti.',
+    'copy_consider_access' => 'Atrašanās vietas, īpašnieka vai piekļuves tiesību izmaiņas var padarīt šo saturu pieejamu citiem, kam iepriekš nav dota piekļuve.',
 ];
index f5e55a603d10499423286ca9dec665c153778db3..9ce8201919b78bb8951ac1a9566a7a6bd39e8bd6 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Ārējās autentifikācijas sistēmas sniegtajos datos nevarēja atrast šī lietotāja e-pasta adresi',
     'saml_invalid_response_id' => 'Ārējās autentifikācijas sistēmas pieprasījums neatpazīst procesu, kuru sākusi šī lietojumprogramma. Pārvietojoties atpakaļ pēc pieteikšanās var rasties šāda problēma.',
     'saml_fail_authed' => 'Piekļuve ar :system neizdevās, sistēma nepieļāva veiksmīgu autorizāciju',
+    'oidc_already_logged_in' => 'Jau esat ielogojies',
+    'oidc_user_not_registered' => 'Lietotājs :name nav reģistrēts un automātiska reģistrācija ir izslēgta',
+    'oidc_no_email_address' => 'Ārējās autentifikācijas sistēmas sniegtajos datos nevarēja atrast šī lietotāja e-pasta adresi',
+    'oidc_fail_authed' => 'Piekļuve ar :system neizdevās, sistēma nepieļāva veiksmīgu autorizāciju',
     'social_no_action_defined' => 'Darbības nav definētas',
     'social_login_bad_response' => "Saņemta kļūda izmantojot :socialAccount piekļuvi:\n:error",
     'social_account_in_use' => 'Šis :socialAccount konts jau tiek izmantots, mēģiniet ieiet ar :socialAccount piekļuves iespēju.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Lapa nav atrasta',
     'sorry_page_not_found' => 'Atvainojiet, meklētā lapa nav atrasta.',
     'sorry_page_not_found_permission_warning' => 'Ja šai lapai būtu bijis te jābūt, jums var nebūt pietiekamas piekļuves tiesības, lai to apskatītu.',
+    'image_not_found' => 'Attēls nav atrasts',
+    'image_not_found_subtitle' => 'Atvainojiet, meklētais attēla fails nav atrasts.',
+    'image_not_found_details' => 'Ja attēlam būtu jābūt pieejamam, iespējams, tas ir ticis izdzēsts.',
     'return_home' => 'Atgriezties uz sākumu',
     'error_occurred' => 'Radusies kļūda',
     'app_down' => ':appName pagaidām nav pieejams',
index decc25c865cc4918838c387890424bcca592e2fc..a5d23584bc7459170ba67f2e61ee5fa088b93fc5 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Apkope',
     'maint_image_cleanup' => 'Tīrīt neizmantotās bildes',
-    'maint_image_cleanup_desc' => "Pārbauda lapu un lapu versiju saturu, lai noteiktu, kuri attēli pašlaik tiek izmantoti, un kuri nav nepieciešami. Pārliecinieties, ka ir veikta pilna datubāzes un attēlu rezerves kopija pirms šīs darbības.",
+    'maint_image_cleanup_desc' => 'Pārbauda lapu un lapu versiju saturu, lai noteiktu, kuri attēli pašlaik tiek izmantoti, un kuri nav nepieciešami. Pārliecinieties, ka ir veikta pilna datubāzes un attēlu rezerves kopija pirms šīs darbības.',
     'maint_delete_images_only_in_revisions' => 'Dzēst arī attēlus, kas izmantoti tikai vecās lapu satura versijās',
     'maint_image_cleanup_run' => 'Veikt tīrīšanu',
     'maint_image_cleanup_warning' => ':count iespējami neizmantoti attēli atrasti. Vai tiešām vēlaties izdzēst šos attēlus?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Miskaste',
     'recycle_bin_desc' => 'Te jūs varat atjaunot dzēstās vienības vai arī izdzēst tās no sistēmas pilnībā. Šis saraksts nav filtrēts atšķirībā no līdzīgiem darbību sarakstiem sistēmā, kur ir piemēroti piekļuves tiesību filtri.',
     'recycle_bin_deleted_item' => 'Dzēsta vienība',
+    'recycle_bin_deleted_parent' => 'Augstāks līmenis',
     'recycle_bin_deleted_by' => 'Izdzēsa',
     'recycle_bin_deleted_at' => 'Dzēšanas laiks',
     'recycle_bin_permanently_delete' => 'Neatgriezeniski izdzēst',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Atjaunojamās vienības',
     'recycle_bin_restore_confirm' => 'Šī darbība atjaunos dzēsto vienību, tai skaitā visus tai pakārtotos elementus, uz tās sākotnējo atrašanās vietu. Ja sākotnējā atrašanās vieta ir izdzēsta un atrodas miskastē, būs nepieciešams atjaunot arī to.',
     'recycle_bin_restore_deleted_parent' => 'Šo elementu saturošā vienība arī ir dzēsta. Tas paliks dzēsts līdz šī saturošā vienība arī ir atjaunota.',
+    'recycle_bin_restore_parent' => 'Atjaunot augstāku līmeni',
     'recycle_bin_destroy_notification' => 'Dzēstas kopā :count vienības no miskastes.',
     'recycle_bin_restore_notification' => 'Atjaunotas kopā :count vienības no miskastes.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Lietotājs',
     'audit_table_event' => 'Notikums',
     'audit_table_related' => 'Saistīta vienība vai detaļa',
+    'audit_table_ip' => 'IP adrese',
     'audit_table_date' => 'Notikuma datums',
     'audit_date_from' => 'Datums no',
     'audit_date_to' => 'Datums līdz',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Informācija par grupu',
     'role_name' => 'Grupas nosaukums',
     'role_desc' => 'Īss grupas apaksts',
+    'role_mfa_enforced' => 'Nepieciešama vairākfaktoru autentifikācija',
     'role_external_auth_id' => 'Ārējais autentifikācijas ID',
     'role_system' => 'Sistēmas atļaujas',
     'role_manage_users' => 'Pārvaldīt lietotājus',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Pārvaldīt lapas veidnes',
     'role_access_api' => 'Piekļūt sistēmas API',
     'role_manage_settings' => 'Pārvaldīt iestatījumus',
+    'role_export_content' => 'Eksportēt saturu',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Lietotāju grupas',
     'users_role_desc' => 'Izvēlēties kurām grupām pievienot lietotāju. Ja lietotājs ir pievienots vairākām grupām, tad lietotājam būs pieejamas visu grupu atļaujas.',
     'users_password' => 'Lietotāja parole',
-    'users_password_desc' => 'Uzstādiet paroli, ar ko piekļūt aplikācijai. Tai jābūt vismaz 6 simbolus garai.',
+    'users_password_desc' => 'Uzstādiet paroli, ar ko piekļūt aplikācijai. Tai jābūt vismaz 8 simbolus garai.',
     'users_send_invite_text' => 'Jūs varat izvēlētes vai nosūtīt šim lietotājam uzaicinājuma epastu, kas ļauj tam uzstādīt savu paroli pašam, vai arī varat uzstādīt paroli tagad.',
     'users_send_invite_option' => 'Nosūtīt lietotāja uzaicinājuma epastu',
     'users_external_auth_id' => 'Ārējais autentifikācijas ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Izveidot žetonu',
     'users_api_tokens_expires' => 'Derīguma termiņš',
     'users_api_tokens_docs' => 'API dokumentācija',
+    'users_mfa' => 'Vairākfaktoru autentifikācija',
+    'users_mfa_desc' => 'Iestati vairākfaktoru autentifikāciju kā papildus drošības līmeni tavam lietotāja kontam.',
+    'users_mfa_x_methods' => ':count metode iestatīta|:count metodes iestatītas',
+    'users_mfa_configure' => 'Iestatīt metodes',
 
     // API Tokens
     'user_api_token_create' => 'Izveidot API žetonu',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Vai tiešām vēlaties dzēst šo API žetonu?',
     'user_api_token_delete_success' => 'API žetons veiksmīgi dzēsts',
 
+    // Webhooks
+    'webhooks' => 'Webhook',
+    'webhooks_create' => 'Izveidot jaunu webhook',
+    'webhooks_none_created' => 'Nav izveidots neviens webhook.',
+    'webhooks_edit' => 'Labot webhook',
+    'webhooks_save' => 'Saglabāt webhook',
+    'webhooks_details' => 'Webhook detaļas',
+    'webhooks_details_desc' => 'Norādiet lietotājiem draudzīgu nosaukumu un POST adresi (endpoint), uz ko nosūtīt webhook datus.',
+    'webhooks_events' => 'Webhook notikumi',
+    'webhooks_events_desc' => 'Izvēlieties visus notikumus, kas izsauks šo webhook.',
+    'webhooks_events_warning' => 'Ņemiet vērā, ka šie notikumi tiks palaisti visiem izvēlētajiem notikumiem, pat ja norādītas pielāgotas piekļuves tiesības. Pārliecineities, ka webhook lietošana neatklās ierobežotas pieejamības saturu.',
+    'webhooks_events_all' => 'Visi sistēmas notikumi',
+    'webhooks_name' => 'Webhook nosaukums',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook adrese (endpoint)',
+    'webhooks_active' => 'Webhook aktīvs',
+    'webhook_events_table_header' => 'Notikumi',
+    'webhooks_delete' => 'Dzēst webhook',
+    'webhooks_delete_warning' => 'Webhook ar nosaukumu \':webhookName\' tiks pilnībā dzēsts no sistēmas.',
+    'webhooks_delete_confirm' => 'Vai tiešām vēlaties dzēst šo webhook?',
+    'webhooks_format_example' => 'Webhook formāta piemērs',
+    'webhooks_format_example_desc' => 'Webhook dati tiek nosūtīti kā POST pieprasījums norādītajai endpoint adresei kā JSON tālāk norādītajā formātā. "related_item" un "url" īpašības nav obligātas un ir atkarīgas no palaistā notikuma veida.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Igauņu',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 6de2b39732706588184380893619f0cfad7906f0..8dd93974615ec6b3c2ad1075af1d9e994d90c86b 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute var saturēt tikai burtus, ciparus, domuzīmes un apakš svītras.',
     'alpha_num'            => ':attribute var saturēt tikai burtus un ciparus.',
     'array'                => ':attribute ir jābūt masīvam.',
+    'backup_codes'         => 'Ievadītais kods nav derīgs vai arī jau ir izmantots.',
     'before'               => ':attribute jābūt datumam pirms :date.',
     'between'              => [
         'numeric' => ':attribute jābūt starp :min un :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute jābūt teksta virknei.',
     'timezone'             => ':attribute jābūt derīgai zonai.',
+    'totp'                 => 'Ievadītais kods nav derīgs.',
     'unique'               => ':attribute jau ir aizņemts.',
     'url'                  => ':attribute formāts nav derīgs.',
     'uploaded'             => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.',
index bf23393abf464f531cd1f7e6f97a7c11ab6bb13f..86efc9376006932b296e7ec5ebfb03b6104e90b3 100644 (file)
@@ -7,42 +7,58 @@ return [
 
     // Pages
     'page_create'                 => 'opprettet side',
-    'page_create_notification'    => 'Siden ble opprettet',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'oppdaterte side',
-    'page_update_notification'    => 'Siden ble oppdatert',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'slettet side',
-    'page_delete_notification'    => 'Siden ble slettet',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'gjenopprettet side',
-    'page_restore_notification'   => 'Siden ble gjenopprettet',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'flyttet side',
 
     // Chapters
     'chapter_create'              => 'opprettet kapittel',
-    'chapter_create_notification' => 'Kapittelet ble opprettet',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'oppdaterte kapittel',
-    'chapter_update_notification' => 'Kapittelet ble oppdatert',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'slettet kapittel',
-    'chapter_delete_notification' => 'Kapittelet ble slettet',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'flyttet kapittel
     ',
 
     // Books
     'book_create'                 => 'opprettet bok',
-    'book_create_notification'    => 'Boken ble opprettet',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'oppdaterte bok',
-    'book_update_notification'    => 'Boken ble oppdatert',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'slettet bok',
-    'book_delete_notification'    => 'Boken ble slettet',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorterte bok',
-    'book_sort_notification'      => 'Boken ble omsortert',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'opprettet bokhylle',
-    'bookshelf_create_notification'    => 'Bokhyllen ble opprettet',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'oppdaterte bokhylle',
-    'bookshelf_update_notification'    => 'Bokhyllen ble oppdatert',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'slettet bokhylle',
-    'bookshelf_delete_notification'    => 'Bokhyllen ble slettet',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '«:name» ble lagt til i dine favoritter',
+    'favourite_remove_notification' => '«:name» ble fjernet fra dine favoritter',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Flerfaktor-metoden ble konfigurert',
+    'mfa_remove_method_notification' => 'Flerfaktor-metoden ble fjernet',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'kommenterte på',
index ae145d28be00f34b8d95af82db42078155d612c2..d80b3258a542183cbe36bca27d2134525d1c2006 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-post',
     'password' => 'Passord',
     'password_confirm' => 'Bekreft passord',
-    'password_hint' => 'Må inneholde 7 tegn',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Glemt passord?',
     'remember_me' => 'Husk meg',
     'ldap_email_hint' => 'Oppgi en e-post for denne kontoen.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Du kan ikke bruke det domenet for å registrere en konto.',
     'register_success' => 'Takk for registreringen! Du kan nå logge inn på tjenesten.',
 
-
     // Password Reset
     'reset_password' => 'Nullstille passord',
     'reset_password_send_instructions' => 'Oppgi e-posten som er koblet til kontoen din, så sender vi en epost hvor du kan nullstille passordet.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Du mottar denne eposten fordi det er blitt bedt om en nullstilling av passord på denne kontoen.',
     'email_reset_not_requested' => 'Om det ikke var deg, så trenger du ikke foreta deg noe.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Bekreft epost-adressen for :appName',
     'email_confirm_greeting' => 'Takk for at du registrerte deg for :appName!',
     'email_confirm_text' => 'Bekreft e-posten din ved å trykke på knappen nedenfor:',
     'email_confirm_action' => 'Bekreft e-post',
     'email_confirm_send_error' => 'Bekreftelse er krevd av systemet, men systemet kan ikke sende disse. Kontakt admin for å løse problemet.',
-    'email_confirm_success' => 'E-posten din er bekreftet!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Bekreftelsespost ble sendt, sjekk innboksen din.',
 
     'email_not_confirmed' => 'E-posten er ikke bekreftet.',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Velkommen til :appName!',
     'user_invite_page_text' => 'For å fullføre prosessen må du oppgi et passord som sikrer din konto på :appName for fremtidige besøk.',
     'user_invite_page_confirm_button' => 'Bekreft passord',
-    'user_invite_success' => 'Passordet er angitt, du kan nå bruke :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Konfigurer flerfaktor-autentisering',
+    'mfa_setup_desc' => 'Konfigurer flerfaktor-autentisering som et ekstra lag med sikkerhet for brukerkontoen din.',
+    'mfa_setup_configured' => 'Allerede konfigurert',
+    'mfa_setup_reconfigure' => 'Omkonfigurer',
+    'mfa_setup_remove_confirmation' => 'Er du sikker på at du vil deaktivere denne flerfaktor-autentiseringsmetoden?',
+    'mfa_setup_action' => 'Konfigurasjon',
+    'mfa_backup_codes_usage_limit_warning' => 'Du har mindre enn 5 sikkerhetskoder igjen; vennligst generer og lagre ett nytt sett før du går tom for koder, for å unngå å bli låst ute av kontoen din.',
+    'mfa_option_totp_title' => 'Mobilapplikasjon',
+    'mfa_option_totp_desc' => 'For å bruke flerfaktorautentisering trenger du en mobilapplikasjon som støtter TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Sikkerhetskoder',
+    'mfa_option_backup_codes_desc' => 'Lagre sikkerhetskoder til engangsbruk på et trygt sted, disse kan du bruke for å verifisere identiteten din.',
+    'mfa_gen_confirm_and_enable' => 'Bekreft og aktiver',
+    'mfa_gen_backup_codes_title' => 'Konfigurasjon av sikkerhetskoder',
+    'mfa_gen_backup_codes_desc' => 'Lagre nedeforstående liste med koder på et trygt sted. Når du skal ha tilgang til systemet kan du bruke en av disse som en faktor under innlogging.',
+    'mfa_gen_backup_codes_download' => 'Last ned koder',
+    'mfa_gen_backup_codes_usage_warning' => 'Hver kode kan kun brukes en gang',
+    'mfa_gen_totp_title' => 'Oppsett for mobilapplikasjon',
+    'mfa_gen_totp_desc' => 'For å bruke flerfaktorautentisering trenger du en mobilapplikasjon som støtter TOTP-teknologien, slik som Google Authenticator, Authy eller Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan QR-koden nedenfor med valgt TOTP-applikasjon for å starte.',
+    'mfa_gen_totp_verify_setup' => 'Bekreft oppsett',
+    'mfa_gen_totp_verify_setup_desc' => 'Bekreft at oppsettet fungerer ved å skrive inn koden fra TOTP-applikasjonen i boksen nedenfor:',
+    'mfa_gen_totp_provide_code_here' => 'Skriv inn den genererte koden her',
+    'mfa_verify_access' => 'Bekreft tilgang',
+    'mfa_verify_access_desc' => 'Brukerkontoen din krever at du bekrefter din identitet med en ekstra autentiseringsfaktor før du får tilgang. Bekreft identiteten med en av dine konfigurerte metoder for å fortsette.',
+    'mfa_verify_no_methods' => 'Ingen metoder er konfigurert',
+    'mfa_verify_no_methods_desc' => 'Ingen flerfaktorautentiseringsmetoder er satt opp for din konto. Du må sette opp minst en metode for å få tilgang.',
+    'mfa_verify_use_totp' => 'Bekreft med mobilapplikasjon',
+    'mfa_verify_use_backup_codes' => 'Bekreft med sikkerhetskode',
+    'mfa_verify_backup_code' => 'Sikkerhetskode',
+    'mfa_verify_backup_code_desc' => 'Skriv inn en av dine ubrukte sikkerhetskoder under:',
+    'mfa_verify_backup_code_enter_here' => 'Skriv inn sikkerhetskode her',
+    'mfa_verify_totp_desc' => 'Skriv inn koden, generert ved hjelp av mobilapplikasjonen, nedenfor:',
+    'mfa_setup_login_notification' => 'Flerfaktorautentisering er konfigurert, vennligst logg inn på nytt med denne metoden.',
+];
index 6d87e54395d180bdfae80bb3c9764815c515081f..cc0a3b6ff0805aef18243d1f1b2a7583f6084930 100644 (file)
@@ -18,9 +18,9 @@ return [
     'name' => 'Navn',
     'description' => 'Beskrivelse',
     'role' => 'Rolle',
-    'cover_image' => 'Bokomslag',
+    'cover_image' => 'Forside',
     'cover_image_description' => 'Bildet bør være ca. 440x250px.',
-    
+
     // Actions
     'actions' => 'Handlinger',
     'view' => 'Vis',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Nullstill',
     'remove' => 'Fjern',
     'add' => 'Legg til',
+    'configure' => 'Konfigurer',
     'fullscreen' => 'Fullskjerm',
+    'favourite' => 'Favorisér',
+    'unfavourite' => 'Avfavorisér',
+    'next' => 'Neste',
+    'previous' => 'Forrige',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sorteringsalternativer',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Stigende sortering',
     'sort_descending' => 'Synkende sortering',
     'sort_name' => 'Navn',
-    'sort_default' => 'Default',
+    'sort_default' => 'Standard',
     'sort_created_at' => 'Dato opprettet',
     'sort_updated_at' => 'Dato oppdatert',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Ingen aktivitet å vise',
     'no_items' => 'Ingen ting å vise',
     'back_to_top' => 'Hopp til toppen',
+    'skip_to_main_content' => 'Gå til hovedinnhold',
     'toggle_details' => 'Vis/skjul detaljer',
     'toggle_thumbnails' => 'Vis/skjul miniatyrbilder',
     'details' => 'Detaljer',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Listevisning',
     'default' => 'Standard',
     'breadcrumb' => 'Brødsmuler',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Utvid toppmeny',
     'profile_menu' => 'Profilmeny',
     'view_profile' => 'Vis profil',
     'edit_profile' => 'Endre Profile',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informasjon',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Fane: Vis tilleggsinfo',
     'tab_content' => 'Innhold',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Fane: Vis hovedinnhold',
 
     // Email Content
     'email_action_help' => 'Om du har problemer med å trykke på «:actionText»-knappen, bruk nettadressen under for å gå direkte dit:',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Personvernregler',
+    'terms_of_service' => 'Bruksvilkår',
 ];
index 9ce2d3cd14f0ac0fb25195b06e13d092b65819e7..d6759dd57c03ef7b486a417b279d339de55681b2 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Bilder',
     'my_recent_drafts' => 'Mine nylige utkast',
     'my_recently_viewed' => 'Mine nylige visninger',
+    'my_most_viewed_favourites' => 'Mine mest sette favoritter',
+    'my_favourites' => 'Mine favoritter',
     'no_pages_viewed' => 'Du har ikke sett på noen sider',
     'no_pages_recently_created' => 'Ingen sider har nylig blitt opprettet',
     'no_pages_recently_updated' => 'Ingen sider har nylig blitt oppdatert',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Nettside med alt',
     'export_pdf' => 'PDF Fil',
     'export_text' => 'Tekstfil',
+    'export_md' => 'Markdownfil',
 
     // Permissions and restrictions
     'permissions' => 'Tilganger',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Tilganger er angitt',
     'search_created_by_me' => 'Opprettet av meg',
     'search_updated_by_me' => 'Oppdatert av meg',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Eid av meg',
     'search_date_options' => 'Datoalternativer',
     'search_updated_before' => 'Oppdatert før',
     'search_updated_after' => 'Oppdatert etter',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Tilganger til hylla',
     'shelves_permissions_updated' => 'Hyllas tilganger er oppdatert',
     'shelves_permissions_active' => 'Hyllas tilganger er aktive',
+    'shelves_permissions_cascade_warning' => 'Tillatelser på bokhyller vil ikke automatisk fordeles til bøkene på aktuell bokhylle. Dette da en bok kan tilhøre flere bokhyller. Tillatelser kan imidlertid kopieres til underliggende bøker ved å benytte alternativet nedenfor.',
     'shelves_copy_permissions_to_books' => 'Kopier tilganger til bøkene på hylla',
     'shelves_copy_permissions' => 'Kopier tilganger',
     'shelves_copy_permissions_explain' => 'Dette vil angi gjeldende tillatelsesinnstillinger for denne bokhyllen på alle bøkene som finnes på den. Før du aktiverer, må du forsikre deg om at endringer i tillatelsene til denne bokhyllen er lagret.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Kapitler sist',
     'books_sort_show_other' => 'Vis andre bøker',
     'books_sort_save' => 'Lagre sortering',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Kapittel',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Flytt kapittel',
     'chapters_move_named' => 'Flytt kapittelet :chapterName',
     'chapter_move_success' => 'Kapittelet ble flyttet til :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Kapitteltilganger',
     'chapters_empty' => 'Det finnes ingen sider i dette kapittelet.',
     'chapters_permissions_active' => 'Kapitteltilganger er aktivert',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Ny side',
     'pages_editing_draft_notification' => 'Du skriver på et utkast som sist ble lagret :timeDiff.',
     'pages_draft_edited_notification' => 'Siden har blitt endret siden du startet. Det anbefales at du forkaster dine endringer.',
+    'pages_draft_page_changed_since_creation' => 'Denne siden er blitt oppdatert etter at dette utkastet ble opprettet. Det anbefales at du forkaster dette utkastet, eller er ekstra forsiktig slik at du ikke overskriver noen sideendringer.',
     'pages_draft_edit_active' => [
         'start_a' => ':count forfattere har begynt å endre denne siden.',
         'start_b' => ':userName skriver på siden for øyeblikket',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Legg til merker for å kategorisere innholdet ditt. \n Du kan legge til merkeverdier for å beskrive dem ytterligere.",
     'tags_add' => 'Legg til flere merker',
     'tags_remove' => 'Fjern merke',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Vedlegg',
     'attachments_explain' => 'Last opp vedlegg eller legg til lenker for å berike innholdet. Disse vil vises i sidestolpen på siden.',
     'attachments_explain_instant_save' => 'Endringer her blir lagret med en gang.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Vil du slette revisjonen?',
     'revision_restore_confirm' => 'Vil du gjenopprette revisjonen? Innholdet på siden vil bli overskrevet med denne revisjonen.',
     'revision_delete_success' => 'Revisjonen ble slettet',
-    'revision_cannot_delete_latest' => 'CKan ikke slette siste revisjon.'
+    'revision_cannot_delete_latest' => 'CKan ikke slette siste revisjon.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 35cee29a453c8c25bd743897923dabd6cf1e18ef..d6f0288b933dadf4138cc136dc6061c2f3535e5d 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Denne kontoinformasjonen finnes ikke i det eksterne autentiseringssystemet.',
     'saml_invalid_response_id' => 'Forespørselen fra det eksterne autentiseringssystemet gjenkjennes ikke av en prosess som startes av dette programmet. Å navigere tilbake etter pålogging kan forårsake dette problemet.',
     'saml_fail_authed' => 'Innlogging gjennom :system feilet. Fikk ikke kontakt med autentiseringstjeneren.',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Ingen handlinger er definert',
     'social_login_bad_response' => "Feilmelding mottat fra :socialAccount innloggingstjeneste: \n:error",
     'social_account_in_use' => 'Denne :socialAccount kontoen er allerede registrert, Prøv å logge inn med :socialAccount alternativet.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Siden finnes ikke',
     'sorry_page_not_found' => 'Beklager, siden du leter etter ble ikke funnet.',
     'sorry_page_not_found_permission_warning' => 'Hvis du forventet at denne siden skulle eksistere, har du kanskje ikke tillatelse til å se den.',
+    'image_not_found' => 'Bildet ble ikke funnet',
+    'image_not_found_subtitle' => 'Beklager, bildefilen du ser etter ble ikke funnet.',
+    'image_not_found_details' => 'Om du forventet at dette bildet skal eksistere, er det mulig det er slettet.',
     'return_home' => 'Gå til hovedside',
     'error_occurred' => 'En feil oppsto',
     'app_down' => ':appName er nede for øyeblikket',
index 4bd35776d59e3b130abab83cfc802ca90dc60e70..e483bf8c12c239e26b1ffa9cf74fbec34e56b302 100644 (file)
@@ -37,11 +37,11 @@ return [
     'app_homepage' => 'Applikasjonens hjemmeside',
     'app_homepage_desc' => 'Velg en visning som skal vises på hjemmesiden i stedet for standardvisningen. Sidetillatelser ignoreres for utvalgte sider.',
     'app_homepage_select' => 'Velg en side',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
-    'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_footer_links' => 'Fotlenker',
+    'app_footer_links_desc' => 'Legg til fotlenker i sidens fotområde. Disse vil vises nederst på de fleste sider, inkludert sider som ikke krever innlogging. Du kan bruke «trans::<key>» etiketter for system-definerte oversettelser. For eksempel: Bruk «trans::common.privacy_policy» for å vise teksten «Personvernregler» og «trans::common.terms_of_service» for å vise teksten «Bruksvilkår».',
+    'app_footer_links_label' => 'Lenketekst',
+    'app_footer_links_url' => 'Lenke',
+    'app_footer_links_add' => 'Legg til fotlenke',
     'app_disable_comments' => 'Deaktiver kommentarer',
     'app_disable_comments_toggle' => 'Deaktiver kommentarer',
     'app_disable_comments_desc' => 'Deaktiver kommentarer på tvers av alle sidene i applikasjonen. <br> Eksisterende kommentarer vises ikke.',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Vedlikehold',
     'maint_image_cleanup' => 'Bildeopprydding',
-    'maint_image_cleanup_desc' => "Skanner side og revisjonsinnhold for å sjekke hvilke bilder og tegninger som for øyeblikket er i bruk, og hvilke bilder som er overflødige. Forsikre deg om at du lager en full database og sikkerhetskopiering av bilder før du kjører denne.",
+    'maint_image_cleanup_desc' => 'Skanner side og revisjonsinnhold for å sjekke hvilke bilder og tegninger som for øyeblikket er i bruk, og hvilke bilder som er overflødige. Forsikre deg om at du lager en full database og sikkerhetskopiering av bilder før du kjører denne.',
     'maint_delete_images_only_in_revisions' => 'Slett også bilder som bare finnes i game siderevisjoner',
     'maint_image_cleanup_run' => 'Kjør opprydding',
     'maint_image_cleanup_warning' => ':count potensielt ubrukte bilder ble funnet. Er du sikker på at du vil slette disse bildene?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Papirkurven',
     'recycle_bin_desc' => 'Her kan du gjenopprette ting du har kastet i papirkurven eller velge å slette dem permanent fra systemet. Denne listen er ikke filtrert i motsetning til lignende lister i systemet hvor tilgangskontroll overholdes.',
     'recycle_bin_deleted_item' => 'Kastet element',
+    'recycle_bin_deleted_parent' => 'Overordnet',
     'recycle_bin_deleted_by' => 'Kastet av',
     'recycle_bin_deleted_at' => 'Kastet den',
     'recycle_bin_permanently_delete' => 'Slett permanent',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Elementer som skal gjenopprettes',
     'recycle_bin_restore_confirm' => 'Denne handlingen vil hente opp elementet fra papirkurven, inkludert underliggende innhold, til sin opprinnelige sted. Om den opprinnelige plassen har blitt slettet i mellomtiden og nå befinner seg i papirkurven, vil også dette bli hentet opp igjen.',
     'recycle_bin_restore_deleted_parent' => 'Det overordnede elementet var også kastet i papirkurven. Disse elementene vil forbli kastet inntil det overordnede også hentes opp igjen.',
+    'recycle_bin_restore_parent' => 'Gjenopprett overodnet',
     'recycle_bin_destroy_notification' => 'Slettet :count elementer fra papirkurven.',
     'recycle_bin_restore_notification' => 'Gjenopprettet :count elementer fra papirkurven.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Kontoholder',
     'audit_table_event' => 'Hendelse',
     'audit_table_related' => 'Relaterte elementer eller detaljer',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Aktivitetsdato',
     'audit_date_from' => 'Datoperiode fra',
     'audit_date_to' => 'Datoperiode til',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Rolledetaljer',
     'role_name' => 'Rollenavn',
     'role_desc' => 'Kort beskrivelse av rolle',
+    'role_mfa_enforced' => 'Krever flerfaktorautentisering',
     'role_external_auth_id' => 'Ekstern godkjennings-ID',
     'role_system' => 'Systemtilganger',
     'role_manage_users' => 'Behandle kontoer',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Behandle sidemaler',
     'role_access_api' => 'Systemtilgang API',
     'role_manage_settings' => 'Behandle applikasjonsinnstillinger',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Roller',
     'users_role_desc' => 'Velg hvilke roller denne kontoholderen vil bli tildelt. Hvis en kontoholderen er tildelt flere roller, vil tillatelsene fra disse rollene stable seg, og de vil motta alle evnene til de tildelte rollene.',
     'users_password' => 'Passord',
-    'users_password_desc' => 'Angi et passord som brukes til å logge på applikasjonen. Dette må bestå av minst 6 tegn.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Du kan velge å sende denne kontoholderen en invitasjons-e-post som lar dem angi sitt eget passord, ellers kan du selv angi passordet.',
     'users_send_invite_option' => 'Send invitasjonsmelding',
     'users_external_auth_id' => 'Ekstern godkjennings-ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Opprett nøkkel',
     'users_api_tokens_expires' => 'Utløper',
     'users_api_tokens_docs' => 'API-dokumentasjon',
+    'users_mfa' => 'Flerfaktorautentisering',
+    'users_mfa_desc' => 'Konfigurer flerfaktorautentisering som et ekstra lag med sikkerhet for din konto.',
+    'users_mfa_x_methods' => ':count metode konfigurert|:count metoder konfigurert',
+    'users_mfa_configure' => 'Konfigurer metoder',
 
     // API Tokens
     'user_api_token_create' => 'Opprett API-nøkkel',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Sikker på at du vil slette nøkkelen?',
     'user_api_token_delete_success' => 'API-nøkkelen ble slettet',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index d06240c7cef1dd11337d66b962062089fe628c08..684645729870e35f9175c683e54cbd5f20a4f80a 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute kan kunne inneholde bokstaver, tall, bindestreker eller understreker.',
     'alpha_num'            => ':attribute kan kun inneholde bokstaver og tall.',
     'array'                => ':attribute må være en liste.',
+    'backup_codes'         => 'Den angitte koden er ikke gyldig, eller er allerede benyttet.',
     'before'               => ':attribute må være en dato før :date.',
     'between'              => [
         'numeric' => ':attribute må være mellom :min og :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute må være en tekststreng.',
     'timezone'             => ':attribute må være en tidssone.',
+    'totp'                 => 'Den angitte koden er ikke gyldig eller har utløpt.',
     'unique'               => ':attribute har allerede blitt tatt.',
     'url'                  => ':attribute format er ugyldig.',
     'uploaded'             => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.',
index f8fb2c34539e44df97391581b338afaad0dc8114..ad3b3355094de7bc52c890e3b8750c66d09340f9 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'maakte pagina',
-    'page_create_notification'    => 'Pagina succesvol aangemaakt',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'wijzigde pagina',
-    'page_update_notification'    => 'Pagina succesvol bijgewerkt',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'verwijderde pagina',
-    'page_delete_notification'    => 'Pagina succesvol verwijderd',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'herstelde pagina',
-    'page_restore_notification'   => 'Pagina succesvol hersteld',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'verplaatste pagina',
 
     // Chapters
     'chapter_create'              => 'maakte hoofdstuk',
-    'chapter_create_notification' => 'Hoofdstuk succesvol aangemaakt',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'wijzigde hoofdstuk',
-    'chapter_update_notification' => 'Hoofdstuk succesvol bijgewerkt',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'verwijderde hoofdstuk',
-    'chapter_delete_notification' => 'Hoofdstuk succesvol verwijderd',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'verplaatste hoofdstuk',
 
     // Books
     'book_create'                 => 'maakte boek',
-    'book_create_notification'    => 'Boek succesvol aangemaakt',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'wijzigde boek',
-    'book_update_notification'    => 'Boek succesvol bijgewerkt',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'verwijderde boek',
-    'book_delete_notification'    => 'Boek succesvol verwijderd',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorteerde boek',
-    'book_sort_notification'      => 'Boek succesvol gesorteerd',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'maakte boekenplank',
-    'bookshelf_create_notification'    => 'Boekenplank succesvol aangemaakt',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'wijzigde boekenplank',
-    'bookshelf_update_notification'    => 'Boekenplank succesvol bijgewerkt',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'verwijderde boekenplank',
-    'bookshelf_delete_notification'    => 'Boekenplank succesvol verwijderd',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" is toegevoegd aan je favorieten',
+    'favourite_remove_notification' => '":name" is verwijderd uit je favorieten',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor methode succesvol geconfigureerd',
+    'mfa_remove_method_notification' => 'Multi-factor methode succesvol verwijderd',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'reageerde op',
index fcff3fd48f0a5b8c4bdf4bbb29595c86dabe0b18..99b82c131bb878a72f04331816c92e1491fd2afc 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Wachtwoord',
     'password_confirm' => 'Wachtwoord bevestigen',
-    'password_hint' => 'Minimaal 8 tekens',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Wachtwoord vergeten?',
     'remember_me' => 'Mij onthouden',
     'ldap_email_hint' => 'Geef een emailadres op voor dit account.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Dit e-maildomein is niet toegestaan',
     'register_success' => 'Bedankt voor het aanmelden! Je bent nu geregistreerd en aangemeld.',
 
-
     // Password Reset
     'reset_password' => 'Wachtwoord herstellen',
     'reset_password_send_instructions' => 'Geef je e-mail en we sturen je een link om je wachtwoord te herstellen',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Je ontvangt deze e-mail omdat je een wachtwoord herstel verzoek had verzonden.',
     'email_reset_not_requested' => 'Als je geen wachtwoord herstel hebt aangevraagd, hoef je niets te doen.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Bevestig je e-mailadres op :appName',
     'email_confirm_greeting' => 'Bedankt voor je aanmelding op :appName!',
     'email_confirm_text' => 'Bevestig je registratie door op onderstaande knop te drukken:',
     'email_confirm_action' => 'Bevestig je e-mail',
     'email_confirm_send_error' => 'E-mail bevestiging is vereisd maar het systeem kon geen mail verzenden. Neem contact op met de beheerder.',
-    'email_confirm_success' => 'Je e-mailadres is bevestigd!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'De bevestigingse-mails is opnieuw verzonden. Controleer je inbox.',
 
     'email_not_confirmed' => 'E-mailadres nog niet bevestigd',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Welkom bij :appName!',
     'user_invite_page_text' => 'Om je account af te ronden en toegang te krijgen moet je een wachtwoord instellen dat gebruikt wordt om in te loggen op :appName bij toekomstige bezoeken.',
     'user_invite_page_confirm_button' => 'Bevestig wachtwoord',
-    'user_invite_success' => 'Wachtwoord ingesteld, je hebt nu toegang tot :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Multi-factor authenticatie instellen',
+    'mfa_setup_desc' => 'Stel multi-factor authenticatie in als een extra beveiligingslaag voor uw gebruikersaccount.',
+    'mfa_setup_configured' => 'Reeds geconfigureerd',
+    'mfa_setup_reconfigure' => 'Herconfigureren1',
+    'mfa_setup_remove_confirmation' => 'Weet je zeker dat je deze multi-factor authenticatie methode wilt verwijderen?',
+    'mfa_setup_action' => 'Instellen',
+    'mfa_backup_codes_usage_limit_warning' => 'U heeft minder dan 5 back-upcodes resterend. Genereer en sla een nieuwe set op voordat je geen codes meer hebt om te voorkomen dat je buiten je account wordt gesloten.',
+    'mfa_option_totp_title' => 'Mobiele app',
+    'mfa_option_totp_desc' => 'Om multi-factor authenticatie te gebruiken heeft u een mobiele applicatie nodig die TOTP ondersteunt, zoals Google Authenticator, Authy of Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Back-up Codes',
+    'mfa_option_backup_codes_desc' => 'Bewaar veilig een set eenmalige back-upcodes die u kunt invoeren om uw identiteit te verifiëren.',
+    'mfa_gen_confirm_and_enable' => 'Bevestigen en inschakelen',
+    'mfa_gen_backup_codes_title' => 'Reservekopiecodes instellen',
+    'mfa_gen_backup_codes_desc' => 'De onderstaande lijst met codes opslaan op een veilige plaats. Bij de toegang tot het systeem kun je een van de codes gebruiken als tweede verificatiemechanisme.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Elke code kan slechts eenmaal gebruikt worden',
+    'mfa_gen_totp_title' => 'Mobiele app installatie',
+    'mfa_gen_totp_desc' => 'Om multi-factor authenticatie te gebruiken heeft u een mobiele applicatie nodig die TOTP ondersteunt, zoals Google Authenticator, Authy of Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan de onderstaande QR-code door gebruik te maken van uw favoriete authenticatie app om aan de slag te gaan.',
+    'mfa_gen_totp_verify_setup' => 'Installatie verifiëren',
+    'mfa_gen_totp_verify_setup_desc' => 'Controleer of alles werkt door het invoeren van een code, die wordt gegenereerd binnen uw authenticatie-app, in het onderstaande invoerveld:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index cfdc1a1a5f7046a538803c640f1dcc98af237441..6ae8348b3b6096286f57bb5a60ea4ee258c38004 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rol',
     'cover_image' => 'Omslagfoto',
     'cover_image_description' => 'Deze afbeelding moet ongeveer 440x250px zijn.',
-    
+
     // Actions
     'actions' => 'Acties',
     'view' => 'Bekijk',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Resetten',
     'remove' => 'Verwijderen',
     'add' => 'Toevoegen',
+    'configure' => 'Configureer',
     'fullscreen' => 'Volledig scherm',
+    'favourite' => 'Favoriet',
+    'unfavourite' => 'Verwijderen uit favoriet',
+    'next' => 'Volgende',
+    'previous' => 'Vorige',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sorteeropties',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Geen activiteit om weer te geven',
     'no_items' => 'Geen items beschikbaar',
     'back_to_top' => 'Terug naar boven',
+    'skip_to_main_content' => 'Direct naar de hoofdinhoud',
     'toggle_details' => 'Details weergeven',
     'toggle_thumbnails' => 'Thumbnails weergeven',
     'details' => 'Details',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Lijstweergave',
     'default' => 'Standaard',
     'breadcrumb' => 'Kruimelpad',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Header menu uitvouwen',
index d0c93700b53ad888aa3e1ceb56d726d3e955ccb5..86146625e85c0df192a73ca72c55eab482e43c51 100644 (file)
@@ -6,45 +6,48 @@
 return [
 
     // Shared
-    'recently_created' => 'Recent Aangemaakt',
-    'recently_created_pages' => 'Recent Aangemaakte Pagina\'s',
-    'recently_updated_pages' => 'Recent Bijgewerkte Pagina\'s',
-    'recently_created_chapters' => 'Recent Aangemaakte Hoofdstukken',
-    'recently_created_books' => 'Recent Aangemaakte Boeken',
-    'recently_created_shelves' => 'Recent Aangemaakte Boekenplanken',
-    'recently_update' => 'Recent Bijgewerkt',
-    'recently_viewed' => 'Recent Bekeken',
-    'recent_activity' => 'Recente Activiteit',
-    'create_now' => 'Maak er zelf één',
+    'recently_created' => 'Recent aangemaakt',
+    'recently_created_pages' => 'Recent aangemaakte pagina\'s',
+    'recently_updated_pages' => 'Recent bijgewerkte pagina\'s',
+    'recently_created_chapters' => 'Recent aangemaakte hoofdstukken',
+    'recently_created_books' => 'Recent aangemaakte boeken',
+    'recently_created_shelves' => 'Recent aangemaakte boekenplanken',
+    'recently_update' => 'Recent bijgewerkt',
+    'recently_viewed' => 'Recent bekeken',
+    'recent_activity' => 'Recente activiteit',
+    'create_now' => 'Maak er nu één',
     'revisions' => 'Revisies',
     'meta_revision' => 'Revisie #:revisionCount',
     'meta_created' => 'Aangemaakt :timeLength',
     'meta_created_name' => 'Aangemaakt: :timeLength door :user',
-    'meta_updated' => ':timeLength Aangepast',
+    'meta_updated' => 'Aangepast: :timeLength',
     'meta_updated_name' => 'Aangepast: :timeLength door :user',
     'meta_owned_name' => 'Eigendom van :user',
-    'entity_select' => 'Entiteit Selecteren',
+    'entity_select' => 'Entiteit selecteren',
     'images' => 'Afbeeldingen',
-    'my_recent_drafts' => 'Mijn Concepten',
-    'my_recently_viewed' => 'Mijn Recent Bekeken',
+    'my_recent_drafts' => 'Mijn concepten',
+    'my_recently_viewed' => 'Mijn recent bekeken',
+    'my_most_viewed_favourites' => 'Mijn meest bekeken favorieten',
+    'my_favourites' => 'Mijn favorieten',
     'no_pages_viewed' => 'Je hebt nog niets bekeken',
     'no_pages_recently_created' => 'Er zijn geen recent aangemaakte pagina\'s',
     'no_pages_recently_updated' => 'Er zijn geen recente wijzigingen',
     'export' => 'Exporteren',
-    'export_html' => 'Ingesloten Webbestand',
-    'export_pdf' => 'PDF Bestand',
-    'export_text' => 'Normaal Tekstbestand',
+    'export_html' => 'Ingesloten webbestand',
+    'export_pdf' => 'PDF bestand',
+    'export_text' => 'Normaal tekstbestand',
+    'export_md' => 'Markdown bestand',
 
     // Permissions and restrictions
     'permissions' => 'Permissies',
     'permissions_intro' => 'Als je dit aanzet, dan gelden rol-permissies niet meer voor deze pagina.',
-    'permissions_enable' => 'Custom Permissies Aanzetten',
-    'permissions_save' => 'Permissies Opslaan',
+    'permissions_enable' => 'Aangepaste permissies aanzetten',
+    'permissions_save' => 'Permissies opslaan',
     'permissions_owner' => 'Eigenaar',
 
     // Search
     'search_results' => 'Zoekresultaten',
-    'search_total_results_found' => ':count resultaten gevonden|:count resultaten gevonden',
+    'search_total_results_found' => ':count resultaten gevonden|:count totaal aantal resultaten gevonden',
     'search_clear' => 'Zoekopdracht wissen',
     'search_no_pages' => 'Er zijn geen pagina\'s gevonden',
     'search_for_term' => 'Zoeken op :term',
@@ -52,16 +55,16 @@ return [
     'search_advanced' => 'Uitgebreid zoeken',
     'search_terms' => 'Zoektermen',
     'search_content_type' => 'Inhoudstype',
-    'search_exact_matches' => 'Exacte Matches',
+    'search_exact_matches' => 'Exacte matches',
     'search_tags' => 'Zoek tags',
     'search_options' => 'Opties',
     'search_viewed_by_me' => 'Bekeken door mij',
     'search_not_viewed_by_me' => 'Niet bekeken door mij',
-    'search_permissions_set' => 'Permissies gezet',
+    'search_permissions_set' => 'Permissies ingesteld',
     'search_created_by_me' => 'Door mij gemaakt',
     'search_updated_by_me' => 'Door mij geupdate',
     'search_owned_by_me' => 'Eigendom van mij',
-    'search_date_options' => 'Datum Opties',
+    'search_date_options' => 'Datum opties',
     'search_updated_before' => 'Geupdate voor',
     'search_updated_after' => 'Geupdate na',
     'search_created_before' => 'Gecreëerd voor',
@@ -75,161 +78,167 @@ return [
     'x_shelves' => ':count Boekenplank|:count Boekenplanken',
     'shelves_long' => 'Boekenplanken',
     'shelves_empty' => 'Er zijn geen boekenplanken aangemaakt',
-    'shelves_create' => 'Nieuwe Boekenplank Aanmaken',
-    'shelves_popular' => 'Populaire Boekenplanken',
-    'shelves_new' => 'Nieuwe Boekenplanken',
-    'shelves_new_action' => 'Nieuwe Boekplank',
+    'shelves_create' => 'Nieuwe boekenplank maken',
+    'shelves_popular' => 'Populaire boekenplanken',
+    'shelves_new' => 'Nieuwe boekenplanken',
+    'shelves_new_action' => 'Nieuwe boekenplank',
     'shelves_popular_empty' => 'De meest populaire boekenplanken worden hier weergegeven.',
     'shelves_new_empty' => 'De meest recent aangemaakt boekenplanken worden hier weergeven.',
-    'shelves_save' => 'Boekenplanken Opslaan',
+    'shelves_save' => 'Boekenplank opslaan',
     'shelves_books' => 'Boeken op deze plank',
-    'shelves_add_books' => 'Toevoegen boeken aan deze plank',
-    'shelves_drag_books' => 'Sleep boeken hier naartoe om deze toe te voegen aan deze plank',
+    'shelves_add_books' => 'Voeg boeken toe aan deze plank',
+    'shelves_drag_books' => 'Sleep boeken hiernaartoe om deze toe te voegen aan deze plank',
     'shelves_empty_contents' => 'Er zijn geen boeken aan deze plank toegekend',
     'shelves_edit_and_assign' => 'Bewerk boekenplank om boeken toe te kennen.',
-    'shelves_edit_named' => 'Bewerk Boekenplank :name',
-    'shelves_edit' => 'Bewerk Boekenplank',
-    'shelves_delete' => 'Verwijder Boekenplank',
-    'shelves_delete_named' => 'Verwijder Boekenplank :name',
+    'shelves_edit_named' => 'Bewerk boekenplank :name',
+    'shelves_edit' => 'Bewerk boekenplank',
+    'shelves_delete' => 'Verwijder boekenplank',
+    'shelves_delete_named' => 'Verwijder boekenplank :name',
     'shelves_delete_explain' => "Deze actie verwijdert de boekenplank met naam ':name'. De boeken op deze plank worden niet verwijderd.",
     'shelves_delete_confirmation' => 'Weet je zeker dat je deze boekenplank wilt verwijderen?',
-    'shelves_permissions' => 'Boekenplank Permissies',
-    'shelves_permissions_updated' => 'Boekenplank Permissies Opgeslagen',
-    'shelves_permissions_active' => 'Boekenplank Permissies Actief',
-    'shelves_copy_permissions_to_books' => 'Kopieer Permissies naar Boeken',
-    'shelves_copy_permissions' => 'Kopieer Permissies',
-    'shelves_copy_permissions_explain' => 'Met deze actie worden de permissies van deze boekenplank gekopieerd naar alle boeken op de plank. Voordat deze actie wordt uitgevoerd, zorg dat de wijzigingen in de permissies van deze boekenplank zijn opgeslagen.',
-    'shelves_copy_permission_success' => 'Boekenplank permissies gekopieerd naar :count boeken',
+    'shelves_permissions' => 'Boekenplank permissies',
+    'shelves_permissions_updated' => 'Boekenplank permissies opgeslagen',
+    'shelves_permissions_active' => 'Boekenplank permissies actief',
+    'shelves_permissions_cascade_warning' => 'Machtigingen op boekenplanken zijn niet automatisch een cascade om boeken te bevatten. Dit komt omdat een boek in meerdere schappen kan bestaan. Machtigingen kunnen echter worden gekopieerd naar subboeken door gebruik te maken van onderstaande optie.',
+    'shelves_copy_permissions_to_books' => 'Kopieer permissies naar boeken',
+    'shelves_copy_permissions' => 'Kopieer permissies',
+    'shelves_copy_permissions_explain' => 'Met deze actie worden de permissies van deze boekenplank gekopieërd naar alle boeken op de plank. Voordat deze actie wordt uitgevoerd, zorg dat de wijzigingen in de permissies van deze boekenplank zijn opgeslagen.',
+    'shelves_copy_permission_success' => 'Boekenplank permissies gekopieërd naar :count boeken',
 
     // Books
     'book' => 'Boek',
     'books' => 'Boeken',
     'x_books' => ':count Boek|:count Boeken',
     'books_empty' => 'Er zijn geen boeken aangemaakt',
-    'books_popular' => 'Populaire Boeken',
-    'books_recent' => 'Recente Boeken',
-    'books_new' => 'Nieuwe Boeken',
-    'books_new_action' => 'Nieuw Boek',
+    'books_popular' => 'Populaire boeken',
+    'books_recent' => 'Recente boeken',
+    'books_new' => 'Nieuwe boeken',
+    'books_new_action' => 'Nieuw boek',
     'books_popular_empty' => 'De meest populaire boeken worden hier weergegeven.',
     'books_new_empty' => 'De meest recent aangemaakte boeken verschijnen hier.',
-    'books_create' => 'Nieuw Boek Aanmaken',
-    'books_delete' => 'Boek Verwijderen',
-    'books_delete_named' => 'Verwijder Boek :bookName',
+    'books_create' => 'Nieuw boek maken',
+    'books_delete' => 'Boek verwijderen',
+    'books_delete_named' => 'Verwijder boek :bookName',
     'books_delete_explain' => 'Deze actie verwijdert het boek \':bookName\', Alle pagina\'s en hoofdstukken worden verwijderd.',
     'books_delete_confirmation' => 'Weet je zeker dat je dit boek wilt verwijderen?',
-    'books_edit' => 'Boek Bewerken',
-    'books_edit_named' => 'Bewerkt Boek :bookName',
-    'books_form_book_name' => 'Boek Naam',
-    'books_save' => 'Boek Opslaan',
-    'books_permissions' => 'Boek Permissies',
-    'books_permissions_updated' => 'Boek Permissies Opgeslagen',
-    'books_empty_contents' => 'Er zijn nog een hoofdstukken en pagina\'s voor dit boek gemaakt.',
-    'books_empty_create_page' => 'Pagina Toevoegen',
+    'books_edit' => 'Boek bewerken',
+    'books_edit_named' => 'Bewerk boek :bookName',
+    'books_form_book_name' => 'Boek naam',
+    'books_save' => 'Boek opslaan',
+    'books_permissions' => 'Boek permissies',
+    'books_permissions_updated' => 'Boek permissies opgeslagen',
+    'books_empty_contents' => 'Er zijn nog geen hoofdstukken en pagina\'s voor dit boek gemaakt.',
+    'books_empty_create_page' => 'Nieuwe pagina maken',
     'books_empty_sort_current_book' => 'Boek sorteren',
-    'books_empty_add_chapter' => 'Hoofdstuk Toevoegen',
-    'books_permissions_active' => 'Boek Permissies Actief',
+    'books_empty_add_chapter' => 'Hoofdstuk toevoegen',
+    'books_permissions_active' => 'Boek permissies actief',
     'books_search_this' => 'Zoeken in dit boek',
-    'books_navigation' => 'Boek Navigatie',
+    'books_navigation' => 'Boek navigatie',
     'books_sort' => 'Inhoud van het boek sorteren',
-    'books_sort_named' => 'Sorteer Boek :bookName',
-    'books_sort_name' => 'Sorteren op Naam',
+    'books_sort_named' => 'Sorteer boek :bookName',
+    'books_sort_name' => 'Sorteren op naam',
     'books_sort_created' => 'Sorteren op datum van aanmaken',
     'books_sort_updated' => 'Sorteren op datum van bijgewerkt',
     'books_sort_chapters_first' => 'Hoofdstukken eerst',
-    'books_sort_chapters_last' => 'Hoofdstukken Laatst',
-    'books_sort_show_other' => 'Bekijk Andere Boeken',
-    'books_sort_save' => 'Nieuwe Order Opslaan',
+    'books_sort_chapters_last' => 'Hoofdstukken laatst',
+    'books_sort_show_other' => 'Bekijk andere boeken',
+    'books_sort_save' => 'Nieuwe volgorde opslaan',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Hoofdstuk',
     'chapters' => 'Hoofdstukken',
     'x_chapters' => ':count Hoofdstuk|:count Hoofdstukken',
-    'chapters_popular' => 'Populaire Hoofdstukken',
-    'chapters_new' => 'Nieuw Hoofdstuk',
-    'chapters_create' => 'Hoofdstuk Toevoegen',
-    'chapters_delete' => 'Hoofdstuk Verwijderen',
-    'chapters_delete_named' => 'Verwijder Hoofdstuk :chapterName',
+    'chapters_popular' => 'Populaire hoofdstukken',
+    'chapters_new' => 'Nieuw hoofdstuk',
+    'chapters_create' => 'Nieuw hoofdstuk maken',
+    'chapters_delete' => 'Hoofdstuk verwijderen',
+    'chapters_delete_named' => 'Verwijder hoofdstuk :chapterName',
     'chapters_delete_explain' => 'Dit verwijdert het hoofdstuk met de naam \':chapterName\'. Alle pagina\'s die binnen dit hoofdstuk staan, worden ook verwijderd.',
     'chapters_delete_confirm' => 'Weet je zeker dat je dit boek wilt verwijderen?',
-    'chapters_edit' => 'Hoofdstuk Aanpassen',
-    'chapters_edit_named' => 'Hoofdstuk :chapterName Aanpassen',
-    'chapters_save' => 'Hoofdstuk Opslaan',
-    'chapters_move' => 'Hoofdstuk Verplaatsen',
-    'chapters_move_named' => 'Verplaatst Hoofdstuk :chapterName',
-    'chapter_move_success' => 'Hoofdstuk Verplaatst Naar :bookName',
-    'chapters_permissions' => 'Hoofdstuk Permissies',
+    'chapters_edit' => 'Hoofdstuk aanpassen',
+    'chapters_edit_named' => 'Hoofdstuk :chapterName aanpassen',
+    'chapters_save' => 'Hoofdstuk opslaan',
+    'chapters_move' => 'Hoofdstuk verplaatsen',
+    'chapters_move_named' => 'Verplaatst hoofdstuk :chapterName',
+    'chapter_move_success' => 'Hoofdstuk verplaatst naar :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
+    'chapters_permissions' => 'Hoofdstuk permissies',
     'chapters_empty' => 'Er zijn geen pagina\'s in dit hoofdstuk aangemaakt.',
-    'chapters_permissions_active' => 'Hoofdstuk Permissies Actief',
-    'chapters_permissions_success' => 'Hoofdstuk Permissies Bijgewerkt',
+    'chapters_permissions_active' => 'Hoofdstuk permissies actief',
+    'chapters_permissions_success' => 'Hoofdstuk permissies bijgewerkt',
     'chapters_search_this' => 'Doorzoek dit hoofdstuk',
 
     // Pages
     'page' => 'Pagina',
     'pages' => 'Pagina\'s',
     'x_pages' => ':count Pagina|:count Pagina\'s',
-    'pages_popular' => 'Populaire Pagina\'s',
-    'pages_new' => 'Nieuwe Pagina',
+    'pages_popular' => 'Populaire pagina\'s',
+    'pages_new' => 'Nieuwe pagina',
     'pages_attachments' => 'Bijlages',
-    'pages_navigation' => 'Pagina Navigatie',
-    'pages_delete' => 'Pagina Verwijderen',
-    'pages_delete_named' => 'Verwijderde Pagina :pageName',
-    'pages_delete_draft_named' => 'Verwijderde Conceptpagina :pageName',
-    'pages_delete_draft' => 'Verwijder Conceptpagina',
+    'pages_navigation' => 'Pagina navigatie',
+    'pages_delete' => 'Pagina verwijderen',
+    'pages_delete_named' => 'Verwijderd pagina :pageName',
+    'pages_delete_draft_named' => 'Verwijder concept pagina :pageName',
+    'pages_delete_draft' => 'Verwijder concept pagina',
     'pages_delete_success' => 'Pagina verwijderd',
     'pages_delete_draft_success' => 'Concept verwijderd',
     'pages_delete_confirm' => 'Weet je zeker dat je deze pagina wilt verwijderen?',
     'pages_delete_draft_confirm' => 'Weet je zeker dat je dit concept wilt verwijderen?',
-    'pages_editing_named' => 'Pagina :pageName Bewerken',
-    'pages_edit_draft_options' => 'Concept Opties',
+    'pages_editing_named' => 'Pagina :pageName bewerken',
+    'pages_edit_draft_options' => 'Concept opties',
     'pages_edit_save_draft' => 'Concept opslaan',
-    'pages_edit_draft' => 'Paginaconcept Bewerken',
-    'pages_editing_draft' => 'Concept Bewerken',
-    'pages_editing_page' => 'Concept Bewerken',
+    'pages_edit_draft' => 'Paginaconcept bewerken',
+    'pages_editing_draft' => 'Concept bewerken',
+    'pages_editing_page' => 'Concept bewerken',
     'pages_edit_draft_save_at' => 'Concept opgeslagen op ',
-    'pages_edit_delete_draft' => 'Concept Verwijderen',
-    'pages_edit_discard_draft' => 'Concept Verwijderen',
+    'pages_edit_delete_draft' => 'Concept verwijderen',
+    'pages_edit_discard_draft' => 'Concept verwijderen',
     'pages_edit_set_changelog' => 'Changelog',
     'pages_edit_enter_changelog_desc' => 'Geef een korte omschrijving van de wijzingen die je gemaakt hebt.',
-    'pages_edit_enter_changelog' => 'Zie logboek',
-    'pages_save' => 'Pagina Opslaan',
-    'pages_title' => 'Pagina Titel',
-    'pages_name' => 'Pagina Naam',
-    'pages_md_editor' => 'Bewerker',
+    'pages_edit_enter_changelog' => 'Zie changelog',
+    'pages_save' => 'Pagina opslaan',
+    'pages_title' => 'Pagina titel',
+    'pages_name' => 'Pagina naam',
+    'pages_md_editor' => 'Bewerken',
     'pages_md_preview' => 'Voorbeeld',
-    'pages_md_insert_image' => 'Afbeelding Invoegen',
-    'pages_md_insert_link' => 'Entity Link Invoegen',
-    'pages_md_insert_drawing' => 'Tekening Toevoegen',
+    'pages_md_insert_image' => 'Afbeelding invoegen',
+    'pages_md_insert_link' => 'Entity link invoegen',
+    'pages_md_insert_drawing' => 'Tekening invoegen',
     'pages_not_in_chapter' => 'Deze pagina staat niet in een hoofdstuk',
-    'pages_move' => 'Pagina Verplaatsten',
+    'pages_move' => 'Pagina verplaatsten',
     'pages_move_success' => 'Pagina verplaatst naar ":parentName"',
-    'pages_copy' => 'Pagina Kopiëren',
-    'pages_copy_desination' => 'Kopieerbestemming',
-    'pages_copy_success' => 'Pagina succesvol gekopieerd',
-    'pages_permissions' => 'Pagina Permissies',
-    'pages_permissions_success' => 'Pagina Permissies bijgwerkt',
+    'pages_copy' => 'Pagina kopiëren',
+    'pages_copy_desination' => 'Kopieër bestemming',
+    'pages_copy_success' => 'Pagina succesvol gekopieërd',
+    'pages_permissions' => 'Pagina permissies',
+    'pages_permissions_success' => 'Pagina permissies bijgewerkt',
     'pages_revision' => 'Revisie',
-    'pages_revisions' => 'Pagina Revisies',
-    'pages_revisions_named' => 'Pagina Revisies voor :pageName',
-    'pages_revision_named' => 'Pagina Revisie voor :pageName',
+    'pages_revisions' => 'Pagina revisies',
+    'pages_revisions_named' => 'Pagina revisies voor :pageName',
+    'pages_revision_named' => 'Pagina revisie voor :pageName',
     'pages_revision_restored_from' => 'Hersteld van #:id; :samenvatting',
     'pages_revisions_created_by' => 'Aangemaakt door',
     'pages_revisions_date' => 'Revisiedatum',
     'pages_revisions_number' => '#',
     'pages_revisions_numbered' => 'Revisie #:id',
-    'pages_revisions_numbered_changes' => 'Revisie #:id Wijzigingen',
-    'pages_revisions_changelog' => 'Wijzigingslogboek',
+    'pages_revisions_numbered_changes' => 'Revisie #:id wijzigingen',
+    'pages_revisions_changelog' => 'Changelog',
     'pages_revisions_changes' => 'Wijzigingen',
-    'pages_revisions_current' => 'Huidige Versie',
+    'pages_revisions_current' => 'Huidige versie',
     'pages_revisions_preview' => 'Voorbeeld',
     'pages_revisions_restore' => 'Herstellen',
     'pages_revisions_none' => 'Deze pagina heeft geen revisies',
-    'pages_copy_link' => 'Link Kopiëren',
+    'pages_copy_link' => 'Link kopiëren',
     'pages_edit_content_link' => 'Bewerk inhoud',
-    'pages_permissions_active' => 'Pagina Permissies Actief',
+    'pages_permissions_active' => 'Pagina permissies actief',
     'pages_initial_revision' => 'Eerste publicatie',
-    'pages_initial_name' => 'Nieuwe Pagina',
+    'pages_initial_name' => 'Nieuwe pagina',
     'pages_editing_draft_notification' => 'U bewerkt momenteel een concept dat voor het laatst is opgeslagen op :timeDiff.',
     'pages_draft_edited_notification' => 'Deze pagina is sindsdien bijgewerkt. Het wordt aanbevolen dat u dit concept verwijderd.',
+    'pages_draft_page_changed_since_creation' => 'Deze pagina is bijgewerkt sinds het aanmaken van dit concept. Het wordt aanbevolen dat u dit ontwerp verwijdert of ervoor zorgt dat u wijzigingen op de pagina niet overschrijft.',
     'pages_draft_edit_active' => [
         'start_a' => ':count gebruikers zijn begonnen deze pagina te bewerken',
         'start_b' => ':userName is begonnen met het bewerken van deze pagina',
@@ -237,40 +246,50 @@ return [
         'time_b' => 'in de laatste :minCount minuten',
         'message' => ':start :time. Let op om elkaars updates niet te overschrijven!',
     ],
-    'pages_draft_discarded' => 'Concept verwijderd, de editor is bijgewerkt met de huidige pagina-inhoud',
-    'pages_specific' => 'Specifieke Pagina',
+    'pages_draft_discarded' => 'Concept verwijderd, de editor is bijgewerkt met de huidige paginainhoud',
+    'pages_specific' => 'Specifieke pagina',
     'pages_is_template' => 'Paginasjabloon',
 
     // Editor Sidebar
     'page_tags' => 'Pagina Labels',
-    'chapter_tags' => 'Tags van Hoofdstuk',
-    'book_tags' => 'Tags van Boeken',
-    'shelf_tags' => 'Tags van Boekplanken',
+    'chapter_tags' => 'Labels van hoofdstuk',
+    'book_tags' => 'Labels van boeken',
+    'shelf_tags' => 'Labels van boekenplanken',
     'tag' => 'Label',
-    'tags' =>  'Tags',
-    'tag_name' =>  'Naam Tag',
-    'tag_value' => 'Label Waarde (Optioneel)',
+    'tags' =>  'Labels',
+    'tag_name' =>  'Naam label',
+    'tag_value' => 'Labelwaarde (Optioneel)',
     'tags_explain' => "Voeg labels toe om de inhoud te categoriseren. \n Je kunt meerdere labels toevoegen.",
     'tags_add' => 'Voeg een extra label toe',
-    'tags_remove' => 'Deze tag verwijderen',
+    'tags_remove' => 'Dit label verwijderen',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Bijlages',
     'attachments_explain' => 'Upload bijlages of voeg een link toe. Deze worden zichtbaar in het navigatiepaneel.',
     'attachments_explain_instant_save' => 'Wijzigingen worden meteen opgeslagen.',
     'attachments_items' => 'Bijlages',
-    'attachments_upload' => 'Bestand Uploaden',
-    'attachments_link' => 'Link Toevoegen',
-    'attachments_set_link' => 'Zet Link',
+    'attachments_upload' => 'Bestand uploaden',
+    'attachments_link' => 'Link toevoegen',
+    'attachments_set_link' => 'Zet link',
     'attachments_delete' => 'Weet u zeker dat u deze bijlage wilt verwijderen?',
     'attachments_dropzone' => 'Sleep hier een bestand of klik hier om een bestand toe te voegen',
     'attachments_no_files' => 'Er zijn geen bestanden geüpload',
     'attachments_explain_link' => 'Je kunt een link toevoegen als je geen bestanden wilt uploaden. Dit kan een link naar een andere pagina op deze website zijn, maar ook een link naar een andere website.',
-    'attachments_link_name' => 'Link Naam',
+    'attachments_link_name' => 'Link naam',
     'attachment_link' => 'Bijlage link',
     'attachments_link_url' => 'Link naar bestand',
-    'attachments_link_url_hint' => 'Url, site of bestand',
+    'attachments_link_url_hint' => 'URL van site of bestand',
     'attach' => 'Koppelen',
     'attachments_insert_link' => 'Bijlage link toevoegen aan pagina',
-    'attachments_edit_file' => 'Bestand Bewerken',
+    'attachments_edit_file' => 'Bestand bewerken',
     'attachments_edit_file_name' => 'Bestandsnaam',
     'attachments_edit_drop_upload' => 'Sleep een bestand of klik hier om te uploaden en te overschrijven',
     'attachments_order_updated' => 'De volgorde van de bijlages is bijgewerkt',
@@ -297,7 +316,7 @@ return [
     // Comments
     'comment' => 'Reactie',
     'comments' => 'Reacties',
-    'comment_add' => 'Reactie Toevoegen',
+    'comment_add' => 'Reactie toevoegen',
     'comment_placeholder' => 'Laat hier een reactie achter',
     'comment_count' => '{0} Geen reacties|{1} 1 Reactie|[2,*] :count Reacties',
     'comment_save' => 'Sla reactie op',
@@ -309,12 +328,20 @@ return [
     'comment_deleted_success' => 'Reactie verwijderd',
     'comment_created_success' => 'Reactie toegevoegd',
     'comment_updated_success' => 'Reactie bijgewerkt',
-    'comment_delete_confirm' => 'Zeker reactie verwijderen?',
+    'comment_delete_confirm' => 'Weet je zeker dat je deze reactie wilt verwijderen?',
     'comment_in_reply_to' => 'Antwoord op :commentId',
 
     // Revision
     'revision_delete_confirm' => 'Weet u zeker dat u deze revisie wilt verwijderen?',
     'revision_restore_confirm' => 'Weet u zeker dat u deze revisie wilt herstellen? De huidige pagina-inhoud wordt vervangen.',
     'revision_delete_success' => 'Revisie verwijderd',
-    'revision_cannot_delete_latest' => 'Kan de laatste revisie niet verwijderen.'
+    'revision_cannot_delete_latest' => 'Kan de laatste revisie niet verwijderen.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 585346748a8825958efd06396f6f9af8048c257c..9bca1d87de654cd98b109b3629722d57821d9541 100644 (file)
@@ -23,7 +23,11 @@ return [
     'saml_no_email_address' => 'Kan geen e-mailadres voor deze gebruiker vinden in de gegevens die door het externe verificatiesysteem worden verstrekt',
     'saml_invalid_response_id' => 'Het verzoek van het externe verificatiesysteem is niet herkend door een door deze applicatie gestart proces. Het terug navigeren na een login kan dit probleem veroorzaken.',
     'saml_fail_authed' => 'Inloggen met :system mislukt, het systeem gaf geen succesvolle autorisatie',
-    'social_no_action_defined' => 'Geen actie gedefineerd',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Geen actie gedefineërd',
     'social_login_bad_response' => "Fout ontvangen tijdens :socialAccount login: \n:error",
     'social_account_in_use' => 'Dit :socialAccount account is al in gebruik, Probeer in te loggen met de :socialAccount optie.',
     'social_account_email_in_use' => 'Het e-mailadres :email is al in gebruik. Als je al een account hebt kun je een :socialAccount account verbinden met je profielinstellingen.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Pagina Niet Gevonden',
     'sorry_page_not_found' => 'Sorry, de pagina die je zocht is niet beschikbaar.',
     'sorry_page_not_found_permission_warning' => 'Als u verwacht dat deze pagina bestaat heeft u misschien geen rechten om het te bekijken.',
+    'image_not_found' => 'Afbeelding niet gevonden',
+    'image_not_found_subtitle' => 'Sorry, de afbeelding die je zocht is niet beschikbaar.',
+    'image_not_found_details' => 'Als u verwachtte dat deze afbeelding zou bestaan, dan is deze misschien verwijderd.',
     'return_home' => 'Terug naar home',
     'error_occurred' => 'Er Ging Iets Fout',
     'app_down' => ':appName is nu niet beschikbaar',
index 3efc20f84d22e6895a012246a8fc0ccddacd849d..1a77d97e662cf9f69d787527729ce2f755154dfe 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Onderhoud',
     'maint_image_cleanup' => 'Afbeeldingen opschonen',
-    'maint_image_cleanup_desc' => "Scant pagina- en revisie inhoud om te controleren welke afbeeldingen en tekeningen momenteel worden gebruikt en welke afbeeldingen overbodig zijn. Zorg ervoor dat je een volledige database en afbeelding backup maakt voordat je dit uitvoert.",
+    'maint_image_cleanup_desc' => 'Scant pagina- en revisie inhoud om te controleren welke afbeeldingen en tekeningen momenteel worden gebruikt en welke afbeeldingen overbodig zijn. Zorg ervoor dat je een volledige database en afbeelding backup maakt voordat je dit uitvoert.',
     'maint_delete_images_only_in_revisions' => 'Ook afbeeldingen die alleen in oude pagina revisies bestaan verwijderen',
     'maint_image_cleanup_run' => 'Opschonen uitvoeren',
     'maint_image_cleanup_warning' => ':count potentieel ongebruikte afbeeldingen gevonden. Weet u zeker dat u deze afbeeldingen wilt verwijderen?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Prullenbak',
     'recycle_bin_desc' => 'Hier kunt u items herstellen die zijn verwijderd of kiezen om ze permanent te verwijderen uit het systeem. Deze lijst is niet gefilterd, in tegenstelling tot vergelijkbare activiteitenlijsten in het systeem waar rechtenfilters worden toegepast.',
     'recycle_bin_deleted_item' => 'Verwijderde Item',
+    'recycle_bin_deleted_parent' => 'Bovenliggende',
     'recycle_bin_deleted_by' => 'Verwijderd door',
     'recycle_bin_deleted_at' => 'Verwijdert op',
     'recycle_bin_permanently_delete' => 'Permanent verwijderen',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items te herstellen',
     'recycle_bin_restore_confirm' => 'Deze actie herstelt het verwijderde item, inclusief alle onderliggende elementen, op hun oorspronkelijke locatie. Als de oorspronkelijke locatie sindsdien is verwijderd en zich nu in de prullenbak bevindt, zal ook het bovenliggende item moeten worden hersteld.',
     'recycle_bin_restore_deleted_parent' => 'De bovenliggende map van dit item is ook verwijderd. Deze zal worden verwijderd totdat het bovenliggende item ook is hersteld.',
+    'recycle_bin_restore_parent' => 'Herstel bovenliggende',
     'recycle_bin_destroy_notification' => 'Verwijderde totaal :count items uit de prullenbak.',
     'recycle_bin_restore_notification' => 'Herstelde totaal :count items uit de prullenbak.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Gebruiker',
     'audit_table_event' => 'Gebeurtenis',
     'audit_table_related' => 'Gerelateerd Item of Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Activiteit datum',
     'audit_date_from' => 'Datum bereik vanaf',
     'audit_date_to' => 'Datum bereik tot',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Rol Details',
     'role_name' => 'Rolnaam',
     'role_desc' => 'Korte beschrijving van de rol',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Externe authenticatie ID\'s',
     'role_system' => 'Systeem Permissies',
     'role_manage_users' => 'Gebruikers beheren',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Paginasjablonen beheren',
     'role_access_api' => 'Ga naar systeem API',
     'role_manage_settings' => 'Beheer app instellingen',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Asset Permissies',
     'roles_system_warning' => 'Wees ervan bewust dat toegang tot een van de bovengenoemde drie machtigingen een gebruiker in staat kan stellen zijn eigen privileges of de privileges van anderen in het systeem te wijzigen. Wijs alleen rollen toe met deze machtigingen aan vertrouwde gebruikers.',
     'role_asset_desc' => 'Deze permissies bepalen de standaardtoegangsrechten. Permissies op boeken, hoofdstukken en pagina\'s overschrijven deze instelling.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Gebruikersrollen',
     'users_role_desc' => 'Selecteer aan welke rollen deze gebruiker zal worden toegewezen. Als een gebruiker aan meerdere rollen wordt toegewezen worden de machtigingen van deze rollen samengevoegd en krijgen ze alle machtigingen van de toegewezen rollen.',
     'users_password' => 'Wachtwoord gebruiker',
-    'users_password_desc' => 'Stel een wachtwoord in dat gebruikt wordt om in te loggen op de applicatie. Dit moet minstens 6 tekens lang zijn.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'U kunt ervoor kiezen om deze gebruiker een uitnodigingsmail te sturen waarmee hij zijn eigen wachtwoord kan instellen, anders kunt u zelf zijn wachtwoord instellen.',
     'users_send_invite_option' => 'Stuur gebruiker uitnodigings e-mail',
     'users_external_auth_id' => 'Externe authenticatie ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Token aanmaken',
     'users_api_tokens_expires' => 'Verloopt',
     'users_api_tokens_docs' => 'API Documentatie',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'API-token aanmaken',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Weet u zeker dat u deze API-token wilt verwijderen?',
     'user_api_token_delete_success' => 'API-token succesvol verwijderd',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index f0e99ad912fd373ad75cbc88203c5d4b82d8a452..c572ce85b4a3ce50f4312296cb5abd3c1a08d48b 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute mag alleen letters, cijfers, streepjes en liggende streepjes bevatten.',
     'alpha_num'            => ':attribute mag alleen letters en nummers bevatten.',
     'array'                => ':attribute moet een reeks zijn.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute moet een datum zijn voor :date.',
     'between'              => [
         'numeric' => ':attribute moet tussen de :min en :max zijn.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute moet tekst zijn.',
     'timezone'             => ':attribute moet een geldige zone zijn.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute is al in gebruik.',
     'url'                  => ':attribute formaat is ongeldig.',
     'uploaded'             => 'Het bestand kon niet worden geüpload. De server accepteert mogelijk geen bestanden van deze grootte.',
index 53f8486daa50b1b3e40db72074cb471f447a1adc..ef013c51043d86fe7740ca13290fcf648c550874 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'utworzono stronę',
-    'page_create_notification'    => 'Strona utworzona pomyślnie',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'zaktualizowano stronę',
-    'page_update_notification'    => 'Strona zaktualizowana pomyślnie',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'usunięto stronę',
-    'page_delete_notification'    => 'Strona usunięta pomyślnie',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'przywrócono stronę',
-    'page_restore_notification'   => 'Strona przywrócona pomyślnie',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'przeniesiono stronę',
 
     // Chapters
     'chapter_create'              => 'utworzono rozdział',
-    'chapter_create_notification' => 'Rozdział utworzony pomyślnie',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'zaktualizowano rozdział',
-    'chapter_update_notification' => 'Rozdział zaktualizowany pomyślnie',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'usunięto rozdział',
-    'chapter_delete_notification' => 'Rozdział usunięty pomyślnie',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'przeniesiono rozdział',
 
     // Books
     'book_create'                 => 'utworzono książkę',
-    'book_create_notification'    => 'Książkę utworzony pomyślnie',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'zaktualizowano książkę',
-    'book_update_notification'    => 'Książkę zaktualizowany pomyślnie',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'usunięto książkę',
-    'book_delete_notification'    => 'Książkę usunięty pomyślnie',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'posortowano książkę',
-    'book_sort_notification'      => 'Książkę posortowany pomyślnie',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'utworzono półkę',
-    'bookshelf_create_notification'    => 'Półka utworzona pomyślnie',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'zaktualizowano półkę',
-    'bookshelf_update_notification'    => 'Półka zaktualizowana pomyślnie',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'usunięto półkę',
-    'bookshelf_delete_notification'    => 'Półka usunięta pomyślnie',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" został dodany do Twoich ulubionych',
+    'favourite_remove_notification' => '":name" został usunięty z ulubionych',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Metoda wieloskładnikowa została pomyślnie skonfigurowana',
+    'mfa_remove_method_notification' => 'Metoda wieloskładnikowa pomyślnie usunięta',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'skomentował',
index 01d74b99cddfbb90fc9bcaf5b278e2c7a14605f5..fe8eb08e9a7f4e2a9633e62cc364f867fbb39e51 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Hasło',
     'password_confirm' => 'Potwierdzenie hasła',
-    'password_hint' => 'Musi mieć więcej niż 7 znaków',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Zapomniałem hasła',
     'remember_me' => 'Zapamiętaj mnie',
     'ldap_email_hint' => 'Wprowadź adres e-mail dla tego konta.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Adresy e-mail z tej domeny nie mają dostępu do tej aplikacji',
     'register_success' => 'Dziękujemy za rejestrację! Zostałeś zalogowany automatycznie.',
 
-
     // Password Reset
     'reset_password' => 'Resetowanie hasła',
     'reset_password_send_instructions' => 'Wprowadź adres e-mail powiązany z Twoim kontem, by otrzymać link do resetowania hasła.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Otrzymujesz tę wiadomość ponieważ ktoś zażądał zresetowania hasła do Twojego konta.',
     'email_reset_not_requested' => 'Jeśli to nie Ty złożyłeś żądanie zresetowania hasła, zignoruj tę wiadomość.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Potwierdź swój adres e-mail w :appName',
     'email_confirm_greeting' => 'Dziękujemy za dołączenie do :appName!',
     'email_confirm_text' => 'Prosimy byś potwierdził swoje hasło klikając przycisk poniżej:',
     'email_confirm_action' => 'Potwierdź e-mail',
     'email_confirm_send_error' => 'Wymagane jest potwierdzenie hasła, lecz wiadomość nie mogła zostać wysłana. Skontaktuj się z administratorem w celu upewnienia się, że skrzynka została skonfigurowana prawidłowo.',
-    'email_confirm_success' => 'Adres e-mail został potwierdzony!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'E-mail z potwierdzeniem został wysłany ponownie, sprawdź swoją skrzynkę odbiorczą.',
 
     'email_not_confirmed' => 'Adres e-mail nie został potwierdzony',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Witaj w :appName!',
     'user_invite_page_text' => 'Aby zakończyć tworzenie konta musisz ustawić hasło, które będzie używane do logowania do :appName w przyszłości.',
     'user_invite_page_confirm_button' => 'Potwierdź hasło',
-    'user_invite_success' => 'Hasło zostało ustawione, teraz masz dostęp do :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe',
+    'mfa_setup_desc' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe jako dodatkową warstwę bezpieczeństwa dla swojego konta użytkownika.',
+    'mfa_setup_configured' => 'Już skonfigurowane',
+    'mfa_setup_reconfigure' => 'Ponownie konfiguruj',
+    'mfa_setup_remove_confirmation' => 'Czy na pewno chcesz usunąć tę metodę uwierzytelniania wieloskładnikowego?',
+    'mfa_setup_action' => 'Konfiguracja',
+    'mfa_backup_codes_usage_limit_warning' => 'Pozostało Ci mniej niż 5 kodów zapasowych, Wygeneruj i przechowuj nowy zestaw zanim skończysz kody, aby zapobiec zablokowaniu się z konta.',
+    'mfa_option_totp_title' => 'Aplikacja mobilna',
+    'mfa_option_totp_desc' => 'Aby korzystać z uwierzytelniania wieloskładnikowego, potrzebujesz aplikacji mobilnej, która obsługuje TOTP, takiej jak Google Authenticator, Authy lub Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Kody zapasowe',
+    'mfa_option_backup_codes_desc' => 'Bezpiecznie przechowuj zestaw jednorazowych kodów zapasowych, które możesz wprowadzić, aby zweryfikować swoją tożsamość.',
+    'mfa_gen_confirm_and_enable' => 'Potwierdź i włącz',
+    'mfa_gen_backup_codes_title' => 'Ustawienia kopii zapasowych kodów',
+    'mfa_gen_backup_codes_desc' => 'Przechowuj poniższą listę kodów w bezpiecznym miejscu. Przy dostępie do systemu będziesz mógł użyć jednego z kodów jako drugiego mechanizmu uwierzytelniania.',
+    'mfa_gen_backup_codes_download' => 'Pobierz kody',
+    'mfa_gen_backup_codes_usage_warning' => 'Każdy kod może być użyty tylko raz',
+    'mfa_gen_totp_title' => 'Ustawienia aplikacji mobilnej',
+    'mfa_gen_totp_desc' => 'Aby korzystać z uwierzytelniania wieloskładnikowego, potrzebujesz aplikacji mobilnej, która obsługuje TOTP, takiej jak Google Authenticator, Authy lub Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Zeskanuj poniższy kod QR za pomocą preferowanej aplikacji uwierzytelniającej, aby rozpocząć.',
+    'mfa_gen_totp_verify_setup' => 'Sprawdź ustawienia',
+    'mfa_gen_totp_verify_setup_desc' => 'Sprawdź, czy wszystko działa wprowadzając kod wygenerowany w twojej aplikacji uwierzytelniającej, w poniższym polu:',
+    'mfa_gen_totp_provide_code_here' => 'Tutaj podaj kod wygenerowany przez aplikację',
+    'mfa_verify_access' => 'Sprawdź dostęp',
+    'mfa_verify_access_desc' => 'Twoje konto wymaga potwierdzenia tożsamości poprzez dodatkowy poziom weryfikacji, zanim uzyskasz dostęp. Zweryfikuj za pomocą jednej z skonfigurowanych metod, aby kontynuować.',
+    'mfa_verify_no_methods' => 'Brak skonfigurowanych metod',
+    'mfa_verify_no_methods_desc' => 'Nie można znaleźć metod uwierzytelniania wieloskładnikowego. Musisz skonfigurować co najmniej jedną metodę zanim uzyskasz dostęp.',
+    'mfa_verify_use_totp' => 'Zweryfikuj używając aplikacji mobilnej',
+    'mfa_verify_use_backup_codes' => 'Zweryfikuj używając kodu zapasowego',
+    'mfa_verify_backup_code' => 'Kod zapasowy',
+    'mfa_verify_backup_code_desc' => 'Wprowadź poniżej jeden z pozostałych kodów zapasowych:',
+    'mfa_verify_backup_code_enter_here' => 'Wprowadź kod zapasowy tutaj',
+    'mfa_verify_totp_desc' => 'Wprowadź kod, wygenerowany przy użyciu aplikacji mobilnej poniżej:',
+    'mfa_setup_login_notification' => 'Metoda wieloskładnikowa skonfigurowana, zaloguj się ponownie za pomocą skonfigurowanej metody.',
+];
index 35d6b6b8292fb7a91633d75b5545d346655e347f..2df45661214d303ff51517abe3530f534dc28293 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rola',
     'cover_image' => 'Zdjęcie z okładki',
     'cover_image_description' => 'Ten obraz powinien posiadać wymiary około 440x250px.',
-    
+
     // Actions
     'actions' => 'Akcje',
     'view' => 'Widok',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Resetuj',
     'remove' => 'Usuń',
     'add' => 'Dodaj',
+    'configure' => 'Konfiguruj',
     'fullscreen' => 'Pełny ekran',
+    'favourite' => 'Ulubione',
+    'unfavourite' => 'Usuń z ulubionych',
+    'next' => 'Dalej',
+    'previous' => 'Wstecz',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Opcje sortowania',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Sortuj rosnąco',
     'sort_descending' => 'Sortuj malejąco',
     'sort_name' => 'Nazwa',
-    'sort_default' => 'Default',
+    'sort_default' => 'Domyślne',
     'sort_created_at' => 'Data utworzenia',
     'sort_updated_at' => 'Data aktualizacji',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Brak aktywności do wyświetlenia',
     'no_items' => 'Brak elementów do wyświetlenia',
     'back_to_top' => 'Powrót na górę',
+    'skip_to_main_content' => 'Przejdź do treści głównej',
     'toggle_details' => 'Włącz/wyłącz szczegóły',
     'toggle_thumbnails' => 'Włącz/wyłącz miniatury',
     'details' => 'Szczegóły',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Widok listy',
     'default' => 'Domyślny',
     'breadcrumb' => 'Ścieżka nawigacji',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Rozwiń menu nagłówka',
     'profile_menu' => 'Menu profilu',
     'view_profile' => 'Zobacz profil',
     'edit_profile' => 'Edytuj profil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informacje',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Zakładka: Pokaż informacje drugorzędne',
     'tab_content' => 'Treść',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Zakładka: Pokaż podstawową zawartość',
 
     // Email Content
     'email_action_help' => 'Jeśli masz problem z kliknięciem przycisku ":actionText", skopiuj i wklej poniższy adres URL w nowej karcie swojej przeglądarki:',
index 4fab043217c0df57ea1a8602d5526e7cac9c9131..4e224715afc80bec29d587cec0486fef34ef846e 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Obrazki',
     'my_recent_drafts' => 'Moje ostatnie wersje robocze',
     'my_recently_viewed' => 'Moje ostatnio wyświetlane',
+    'my_most_viewed_favourites' => 'Moje najczęściej przeglądane ulubione',
+    'my_favourites' => 'Moje ulubione',
     'no_pages_viewed' => 'Nie przeglądałeś jeszcze żadnych stron',
     'no_pages_recently_created' => 'Nie utworzono ostatnio żadnych stron',
     'no_pages_recently_updated' => 'Nie zaktualizowano ostatnio żadnych stron',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Plik HTML',
     'export_pdf' => 'Plik PDF',
     'export_text' => 'Plik tekstowy',
+    'export_md' => 'Pliki Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Uprawnienia',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Zbiór uprawnień',
     'search_created_by_me' => 'Utworzone przeze mnie',
     'search_updated_by_me' => 'Zaktualizowane przeze mnie',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Należące do mnie',
     'search_date_options' => 'Opcje dat',
     'search_updated_before' => 'Zaktualizowane przed',
     'search_updated_after' => 'Zaktualizowane po',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Uprawnienia półki',
     'shelves_permissions_updated' => 'Uprawnienia półki zostały zaktualizowane',
     'shelves_permissions_active' => 'Uprawnienia półki są aktywne',
+    'shelves_permissions_cascade_warning' => 'Uprawnienia na półkach nie są automatycznie kaskadowane do zawartych w nich książek. Dzieje się tak dlatego, że książka może istnieć na wielu półkach. Zezwolenia można jednak skopiować do książek podrzędnych, korzystając z opcji znajdującej się poniżej.',
     'shelves_copy_permissions_to_books' => 'Skopiuj uprawnienia do książek',
     'shelves_copy_permissions' => 'Skopiuj uprawnienia',
     'shelves_copy_permissions_explain' => 'To spowoduje zastosowanie obecnych ustawień uprawnień dla tej półki do wszystkich książek w niej zawartych. Przed aktywacją upewnij się, że wszelkie zmiany w uprawnieniach do tej półki zostały zapisane.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Rozdziały na końcu',
     'books_sort_show_other' => 'Pokaż inne książki',
     'books_sort_save' => 'Zapisz nową kolejność',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Rozdział',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Przenieś rozdział',
     'chapters_move_named' => 'Przenieś rozdział :chapterName',
     'chapter_move_success' => 'Rozdział przeniesiony do :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Uprawienia rozdziału',
     'chapters_empty' => 'Brak stron w tym rozdziale.',
     'chapters_permissions_active' => 'Uprawnienia rozdziału są aktywne',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nowa strona',
     'pages_editing_draft_notification' => 'Edytujesz obecnie wersje roboczą, która była ostatnio zapisana :timeDiff.',
     'pages_draft_edited_notification' => 'Od tego czasu ta strona była zmieniana. Zalecane jest odrzucenie tej wersji roboczej.',
+    'pages_draft_page_changed_since_creation' => 'Ta strona została zaktualizowana od czasu utworzenia tego szkicu. Zaleca się, aby odrzucić ten szkic lub nie nadpisywać żadnych zmian na stronie.',
     'pages_draft_edit_active' => [
         'start_a' => ':count użytkowników rozpoczęło edytowanie tej strony',
         'start_b' => ':userName edytuje stronę',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Dodaj tagi by skategoryzować zawartość. \n W celu dokładniejszej organizacji zawartości możesz dodać wartości do tagów.",
     'tags_add' => 'Dodaj kolejny tag',
     'tags_remove' => 'Usuń ten tag',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Załączniki',
     'attachments_explain' => 'Prześlij kilka plików lub załącz linki. Będą one widoczne na pasku bocznym strony.',
     'attachments_explain_instant_save' => 'Zmiany są zapisywane natychmiastowo.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Czy na pewno chcesz usunąć tę wersję?',
     'revision_restore_confirm' => 'Czu ma pewno chcesz przywrócić tą wersję? Aktualna zawartość strony zostanie nadpisana.',
     'revision_delete_success' => 'Usunięto wersję',
-    'revision_cannot_delete_latest' => 'Nie można usunąć najnowszej wersji.'
+    'revision_cannot_delete_latest' => 'Nie można usunąć najnowszej wersji.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 5f5ec9db25150366d9c90c0f164814cb67a3386f..e493705b51ecb72cbeaf51517ca5c6c03e905fd5 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Nie można odnaleźć adresu email dla tego użytkownika w danych dostarczonych przez zewnętrzny system uwierzytelniania',
     'saml_invalid_response_id' => 'Żądanie z zewnętrznego systemu uwierzytelniania nie zostało rozpoznane przez proces rozpoczęty przez tę aplikację. Cofnięcie po zalogowaniu mogło spowodować ten problem.',
     'saml_fail_authed' => 'Logowanie przy użyciu :system nie powiodło się, system nie mógł pomyślnie ukończyć uwierzytelniania',
+    'oidc_already_logged_in' => 'Już zalogowany',
+    'oidc_user_not_registered' => 'Użytkownik :name nie jest zarejestrowany, a automatyczna rejestracja jest wyłączona',
+    'oidc_no_email_address' => 'Nie można odnaleźć adresu email dla tego użytkownika w danych dostarczonych przez zewnętrzny system uwierzytelniania',
+    'oidc_fail_authed' => 'Logowanie przy użyciu :system nie powiodło się, system nie mógł pomyślnie ukończyć uwierzytelniania',
     'social_no_action_defined' => 'Brak zdefiniowanej akcji',
     'social_login_bad_response' => "Podczas próby logowania :socialAccount wystąpił błąd: \n:error",
     'social_account_in_use' => 'To konto :socialAccount jest już w użyciu. Spróbuj zalogować się za pomocą opcji :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Strona nie została znaleziona',
     'sorry_page_not_found' => 'Przepraszamy, ale strona której szukasz nie została znaleziona.',
     'sorry_page_not_found_permission_warning' => 'Jeśli spodziewałeś się, że ta strona istnieje, prawdopodobnie nie masz uprawnień do jej wyświetlenia.',
+    'image_not_found' => 'Nie znaleziono obrazu',
+    'image_not_found_subtitle' => 'Przepraszamy, ale obraz którego szukasz nie został znaleziony.',
+    'image_not_found_details' => 'Jeśli spodziewałeś się, że ten obraz istnieje, mógł on zostać usunięty.',
     'return_home' => 'Powrót do strony głównej',
     'error_occurred' => 'Wystąpił błąd',
     'app_down' => ':appName jest aktualnie wyłączona',
index 8e8a039482cd6064a1e3dbb80298f6fe6ad2b910..bebccf5f897ccce79ae7e9ab38d8c103451edab1 100644 (file)
@@ -16,7 +16,7 @@ return [
     'app_features_security' => 'Funkcje i bezpieczeństwo',
     'app_name' => 'Nazwa aplikacji',
     'app_name_desc' => 'Ta nazwa jest wyświetlana w nagłówku i e-mailach.',
-    'app_name_header' => 'Pokazać nazwę aplikacji w nagłówku?',
+    'app_name_header' => 'Pokaż nazwę aplikacji w nagłówku',
     'app_public_access' => 'Dostęp publiczny',
     'app_public_access_desc' => 'Włączenie tej opcji umożliwi niezalogowanym odwiedzającym dostęp do treści w Twojej instancji BookStack.',
     'app_public_access_desc_guest' => 'Dostęp dla niezalogowanych odwiedzających jest dostępny poprzez użytkownika "Guest".',
@@ -59,7 +59,7 @@ return [
     'reg_settings' => 'Ustawienia rejestracji',
     'reg_enable' => 'Włącz rejestrację',
     'reg_enable_toggle' => 'Włącz rejestrację',
-    'reg_enable_desc' => 'Kiedy rejestracja jest włączona użytkownicy mogą się rejestrować. Po rejestracji otrzymują jedną domyślną rolę użytkownika.',
+    'reg_enable_desc' => 'Po włączeniu rejestracji użytkownicy ci będą mogli się samodzielnie zarejestrować i otrzymają domyślną rolę.',
     'reg_default_role' => 'Domyślna rola użytkownika po rejestracji',
     'reg_enable_external_warning' => 'Powyższa opcja jest ignorowana, gdy zewnętrzne uwierzytelnianie LDAP lub SAML jest aktywne. Konta użytkowników dla nieistniejących użytkowników zostaną automatycznie utworzone, jeśli uwierzytelnianie za pomocą systemu zewnętrznego zakończy się sukcesem.',
     'reg_email_confirmation' => 'Potwierdzenie adresu email',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Konserwacja',
     'maint_image_cleanup' => 'Czyszczenie obrazków',
-    'maint_image_cleanup_desc' => "Skanuje zawartość strony i poprzednie wersje, aby sprawdzić, które obrazy i rysunki są aktualnie używane, a które obrazy są zbędne. Przed uruchomieniem tej opcji należy utworzyć pełną kopię zapasową bazy danych i obrazków.",
+    'maint_image_cleanup_desc' => 'Skanuje zawartość strony i poprzednie wersje, aby sprawdzić, które obrazy i rysunki są aktualnie używane, a które obrazy są zbędne. Przed uruchomieniem tej opcji należy utworzyć pełną kopię zapasową bazy danych i obrazków.',
     'maint_delete_images_only_in_revisions' => 'Usuń również obrazy, które istnieją tylko w starych rewizjach strony',
     'maint_image_cleanup_run' => 'Uruchom czyszczenie',
     'maint_image_cleanup_warning' => 'Znaleziono :count potencjalnie niepotrzebnych obrazków. Czy na pewno chcesz je usunąć?',
@@ -90,26 +90,28 @@ return [
 
     // Recycle Bin
     'recycle_bin' => 'Kosz',
-    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
+    'recycle_bin_desc' => 'Tutaj możesz przywrócić elementy, które zostały usunięte lub usunąć je z systemu. Ta lista jest niefiltrowana w odróżnieniu od podobnych list aktywności w systemie, w którym stosowane są filtry uprawnień.',
     'recycle_bin_deleted_item' => 'Usunięta pozycja',
+    'recycle_bin_deleted_parent' => 'Nadrzędny',
     'recycle_bin_deleted_by' => 'Usunięty przez',
     'recycle_bin_deleted_at' => 'Czas usunięcia',
     'recycle_bin_permanently_delete' => 'Usuń trwale',
     'recycle_bin_restore' => 'Przywróć',
     'recycle_bin_contents_empty' => 'Kosz jest pusty',
     'recycle_bin_empty' => 'Opróżnij kosz',
-    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
+    'recycle_bin_empty_confirm' => 'To na stałe zniszczy wszystkie przedmioty w koszu, w tym zawartość w każdym elemencie. Czy na pewno chcesz opróżnić kosz?',
+    'recycle_bin_destroy_confirm' => 'Ta akcja trwale usunie ten element, wraz z elementami podrzędnymi wymienionymi poniżej, z systemu i nie będziesz w stanie przywrócić tej zawartości. Czy na pewno chcesz trwale usunąć ten element?',
+    'recycle_bin_destroy_list' => 'Elementy do usunięcia',
     'recycle_bin_restore_list' => 'Elementy do przywrócenia',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
-    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin_restore_confirm' => 'Ta akcja przywróci usunięty element, w tym elementy podrzędne, do ich oryginalnej lokalizacji. Jeśli oryginalna lokalizacja została od tego czasu usunięta, a teraz znajduje się w koszu, element nadrzędny będzie również musiał zostać przywrócony.',
+    'recycle_bin_restore_deleted_parent' => 'Usunięto również nadrzędny element. Zostaną one usunięte, dopóki nie przywróci się tego nadrzędnego elementu.',
+    'recycle_bin_restore_parent' => 'Przywróć nadrzędne',
+    'recycle_bin_destroy_notification' => 'Usunięto :count przedmiotów z kosza.',
+    'recycle_bin_restore_notification' => 'Przywrócono :count przedmiotów z kosza.',
 
     // Audit Log
     'audit' => 'Dziennik audytu',
-    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
+    'audit_desc' => 'Ten dziennik audytu wyświetla listę działań śledzonych w systemie. Ta lista jest niefiltrowana w odróżnieniu od podobnych list aktywności w systemie, w którym stosowane są filtry uprawnień.',
     'audit_event_filter' => 'Filtry Wydarzeń',
     'audit_event_filter_no_filter' => 'Brak filtra',
     'audit_deleted_item' => 'Usunięta pozycja',
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Użytkownik',
     'audit_table_event' => 'Wydarzenie',
     'audit_table_related' => 'Powiązany element lub szczegóły',
+    'audit_table_ip' => 'Adres IP',
     'audit_table_date' => 'Data Aktywności',
     'audit_date_from' => 'Zakres dat od',
     'audit_date_to' => 'Zakres dat do',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Szczegóły roli',
     'role_name' => 'Nazwa roli',
     'role_desc' => 'Krótki opis roli',
+    'role_mfa_enforced' => 'Wymaga uwierzytelniania wieloetapowego',
     'role_external_auth_id' => 'Zewnętrzne identyfikatory uwierzytelniania',
     'role_system' => 'Uprawnienia systemowe',
     'role_manage_users' => 'Zarządzanie użytkownikami',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Zarządzaj szablonami stron',
     'role_access_api' => 'Dostęp do systemowego API',
     'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji',
+    'role_export_content' => 'Eksportuj zawartość',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Role użytkownika',
     'users_role_desc' => 'Wybierz role, do których ten użytkownik zostanie przypisany. Jeśli użytkownik jest przypisany do wielu ról, uprawnienia z tych ról zostaną nałożone i otrzyma wszystkie uprawnienia przypisanych ról.',
     'users_password' => 'Hasło użytkownika',
-    'users_password_desc' => 'Ustaw hasło logowania do aplikacji. Hasło musi mieć przynajmniej 6 znaków.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Możesz wybrać wysłanie do tego użytkownika wiadomości e-mail z zaproszeniem, która pozwala mu ustawić własne hasło, w przeciwnym razie możesz ustawić je samemu.',
     'users_send_invite_option' => 'Wyślij e-mail z zaproszeniem',
     'users_external_auth_id' => 'Zewnętrzne identyfikatory autentykacji',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Utwórz token',
     'users_api_tokens_expires' => 'Wygasa',
     'users_api_tokens_docs' => 'Dokumentacja API',
+    'users_mfa' => 'Uwierzytelnianie wieloskładnikowe',
+    'users_mfa_desc' => 'Skonfiguruj uwierzytelnianie wieloskładnikowe jako dodatkową warstwę bezpieczeństwa dla swojego konta użytkownika.',
+    'users_mfa_x_methods' => ':count metoda skonfigurowana|:count metody skonfigurowane',
+    'users_mfa_configure' => 'Konfiguruj metody',
 
     // API Tokens
     'user_api_token_create' => 'Utwórz klucz API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Czy jesteś pewien, że chcesz usunąć ten token?',
     'user_api_token_delete_success' => 'Token API został poprawnie usunięty',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Estoński',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 76ff49a6a4fcb96e13fdfe629785b499494277c6..ab4c9da7b9d689f3d3ad09919bbd3db3d7b0d691 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute może zawierać wyłącznie litery, cyfry i myślniki.',
     'alpha_num'            => ':attribute może zawierać wyłącznie litery i cyfry.',
     'array'                => ':attribute musi być tablicą.',
+    'backup_codes'         => 'Podany kod jest nieprawidłowy lub został już użyty.',
     'before'               => ':attribute musi być datą poprzedzającą :date.',
     'between'              => [
         'numeric' => ':attribute musi zawierać się w przedziale od :min do :max.',
@@ -51,7 +52,7 @@ return [
     'integer'              => ':attribute musi być liczbą całkowitą.',
     'ip'                   => ':attribute musi być prawidłowym adresem IP.',
     'ipv4'                 => ':attribute musi być prawidłowym adresem IPv4.',
-    'ipv6'                 => ':attribute musi być prawidłowym adresem  IPv6.',
+    'ipv6'                 => ':attribute musi być prawidłowym adresem IPv6.',
     'json'                 => ':attribute musi być prawidłowym ciągiem JSON.',
     'lt'                   => [
         'numeric' => ':attribute musi być mniejszy niż :value.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute musi być ciągiem znaków.',
     'timezone'             => ':attribute musi być prawidłową strefą czasową.',
+    'totp'                 => 'Podany kod jest nieprawidłowy lub wygasł.',
     'unique'               => ':attribute zostało już zajęte.',
     'url'                  => 'Format :attribute jest nieprawidłowy.',
     'uploaded'             => 'Plik nie może zostać wysłany. Serwer nie akceptuje plików o takim rozmiarze.',
index 8a5d15e8f91c5a00b14133a216b6c372447b2827..549908f810cc038aa83f78c679f9826ae4b04d1e 100644 (file)
@@ -6,42 +6,58 @@
 return [
 
     // Pages
-    'page_create'                 => 'página criada',
-    'page_create_notification'    => 'Página criada com sucesso',
+    'page_create'                 => 'criou a página',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'página atualizada',
-    'page_update_notification'    => 'Página atualizada com sucesso',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'página eliminada',
-    'page_delete_notification'    => 'Página eliminada com sucesso',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'página restaurada',
-    'page_restore_notification'   => 'Página restaurada com sucesso',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'página movida',
 
     // Chapters
     'chapter_create'              => 'capítulo criado',
-    'chapter_create_notification' => 'Capítulo criado com sucesso',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'capítulo atualizado',
-    'chapter_update_notification' => 'Capítulo atualizado com sucesso',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'capítulo excluído',
-    'chapter_delete_notification' => 'Capítulo excluído com sucesso',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'capítulo movido',
 
     // Books
     'book_create'                 => 'livro criado',
-    'book_create_notification'    => 'Livro criado com sucesso',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'livro atualizado',
-    'book_update_notification'    => 'Livro atualizado com sucesso',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'livro eliminado',
-    'book_delete_notification'    => 'Livro eliminado com sucesso',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'livro ordenado',
-    'book_sort_notification'      => 'Livro reordenado com sucesso',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'estante criada',
-    'bookshelf_create_notification'    => 'Estante criada com sucesso',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'estante atualizada',
-    'bookshelf_update_notification'    => 'Estante atualizada com sucesso',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'excluiu a prateleira',
-    'bookshelf_delete_notification'    => 'Estante eliminada com sucesso',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" foi adicionado aos seus favoritos',
+    'favourite_remove_notification' => '":name" foi removido dos seus favoritos',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Método de múltiplos-fatores configurado com sucesso',
+    'mfa_remove_method_notification' => 'Método de múltiplos-fatores removido com sucesso',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'comentado a',
index 7d520a5f7d3228a77484b688898b1260fde34d92..82fb9aa236b892059d6b8823f9af1405bccc013a 100644 (file)
@@ -6,11 +6,11 @@
  */
 return [
 
-    'failed' => 'As credenciais fornecidas não correspondem aos nossos registos.',
-    'throttle' => 'Demasiadas tentativas de início de sessão. Por favor, tente novamente em :seconds segundos.',
+    'failed' => 'Estas credenciais não coincidem com os nossos registos.',
+    'throttle' => 'Demasiadas tentativas de acesso. Tente novamente em :seconds segundos.',
 
     // Login & Register
-    'sign_up' => 'Criar conta',
+    'sign_up' => 'Registar',
     'log_in' => 'Iniciar sessão',
     'log_in_with' => 'Iniciar sessão com :socialDriver',
     'sign_up_with' => 'Criar conta com :socialDriver',
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Palavra-passe',
     'password_confirm' => 'Confirmar Palavra-passe',
-    'password_hint' => 'Deve ser maior que 7 caracteres',
+    'password_hint' => 'Deve ter no mínimo 8 caracteres',
     'forgot_password' => 'Esqueceu-se da palavra-passe?',
     'remember_me' => 'Lembrar-se de mim',
     'ldap_email_hint' => 'Por favor insira um endereço de e-mail para esta conta.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'O domínio de e-mail usado não tem acesso permitido a esta aplicação',
     'register_success' => 'Obrigado por se registar! Você está agora registado e com a sessão iniciada.',
 
-
     // Password Reset
     'reset_password' => 'Redefinir Senha',
     'reset_password_send_instructions' => 'Insira o seu endereço de e-mail abaixo, e uma mensagem com o link de redefinição de palavra-passe será lhe enviada.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Você recebeu este e-mail pois recebemos uma solicitação de redefinição de senha para a sua conta.',
     'email_reset_not_requested' => 'Caso não tenha sido você a solicitar a redefinição de senha, ignore este e-mail.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirme o seu endereço de e-mail para :appName',
     'email_confirm_greeting' => 'Obrigado por se registar em :appName!',
     'email_confirm_text' => 'Por favor, confirme o seu endereço de e-mail ao carregar no botão abaixo:',
     'email_confirm_action' => 'Confirmar E-mail',
     'email_confirm_send_error' => 'A confirmação do endereço de e-mail é requerida, mas o sistema não pôde enviar a mensagem. Por favor, entre em contacto com o administrador para se certificar que o serviço de envio de e-mails está corretamente configurado.',
-    'email_confirm_success' => 'O seu endereço de e-mail foi confirmado!',
+    'email_confirm_success' => 'O seu endereço de email foi confirmado! Neste momento já poderá entrar usando este endereço de email.',
     'email_confirm_resent' => 'E-mail de confirmação reenviado. Por favor, verifique a sua caixa de entrada.',
 
     'email_not_confirmed' => 'Endereço de E-mail Não Confirmado',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Bem-vindo(a) a :appName!',
     'user_invite_page_text' => 'Para finalizar a sua conta e obter acesso, precisa de definir uma senha que será utilizada para efetuar login em :appName em visitas futuras.',
     'user_invite_page_confirm_button' => 'Confirmar Palavra-Passe',
-    'user_invite_success' => 'Palavra-passe definida, tem agora acesso a :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Palavra passe definida, agora poderá entrar usado a sua nova palavra passe para acessar :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Configurar autenticação de múltiplos fatores',
+    'mfa_setup_desc' => 'Configure a autenticação multi-fatores como uma camada extra de segurança para sua conta de utilizador.',
+    'mfa_setup_configured' => 'Já configurado',
+    'mfa_setup_reconfigure' => 'Reconfigurar',
+    'mfa_setup_remove_confirmation' => 'Tem a certeza que deseja remover este método de autenticação de múltiplos fatores?',
+    'mfa_setup_action' => 'Configuração',
+    'mfa_backup_codes_usage_limit_warning' => 'Você tem menos de 5 códigos de backup restantes, Por favor, gere e armazene um novo conjunto antes de esgotar os códigos para evitar estar bloqueado para fora da sua conta.',
+    'mfa_option_totp_title' => 'Aplicação móvel',
+    'mfa_option_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Autenticador do Google, Authy ou o autenticador Microsoft.',
+    'mfa_option_backup_codes_title' => 'Códigos de Backup',
+    'mfa_option_backup_codes_desc' => 'Armazene com segurança um conjunto de códigos de backup únicos que você pode inserir para verificar sua identidade.',
+    'mfa_gen_confirm_and_enable' => 'Confirmar e ativar',
+    'mfa_gen_backup_codes_title' => 'Configuração dos Códigos de Backup',
+    'mfa_gen_backup_codes_desc' => 'Armazene a lista de códigos abaixo em um lugar seguro. Ao acessar o sistema você poderá usar um dos códigos como um segundo mecanismo de autenticação.',
+    'mfa_gen_backup_codes_download' => 'Transferir códigos',
+    'mfa_gen_backup_codes_usage_warning' => 'Cada código só pode ser usado uma vez',
+    'mfa_gen_totp_title' => 'Configuração de aplicativo móvel',
+    'mfa_gen_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Autenticador do Google, Authy ou o autenticador Microsoft.',
+    'mfa_gen_totp_scan' => 'Leia o código QR abaixo usando seu aplicativo de autenticação preferido para começar.',
+    'mfa_gen_totp_verify_setup' => 'Verificar configuração',
+    'mfa_gen_totp_verify_setup_desc' => 'Verifique se tudo está funcionando digitando um código, gerado dentro do seu aplicativo de autenticação, na caixa de entrada abaixo:',
+    'mfa_gen_totp_provide_code_here' => 'Forneça o código gerado pelo aplicativo aqui',
+    'mfa_verify_access' => 'Verificar Acesso',
+    'mfa_verify_access_desc' => 'Sua conta de usuário requer que você confirme sua identidade por meio de um nível adicional de verificação antes de conceder o acesso. Verifique o uso de um dos métodos configurados para continuar.',
+    'mfa_verify_no_methods' => 'Nenhum método configurado',
+    'mfa_verify_no_methods_desc' => 'Nenhum método de autenticação de vários fatores foi encontrado para a sua conta. Você precisará configurar pelo menos um método antes de ganhar acesso.',
+    'mfa_verify_use_totp' => 'Verificar usando um aplicativo móvel',
+    'mfa_verify_use_backup_codes' => 'Verificar usando código de backup',
+    'mfa_verify_backup_code' => 'Código de backup',
+    'mfa_verify_backup_code_desc' => 'Insira um dos seus códigos de backup restantes abaixo:',
+    'mfa_verify_backup_code_enter_here' => 'Insira o código de backup aqui',
+    'mfa_verify_totp_desc' => 'Digite o código, gerado através do seu aplicativo móvel, abaixo:',
+    'mfa_setup_login_notification' => 'Método de multi-fatores configurado, por favor faça login novamente usando o método configurado.',
+];
index bc76d775369037f8231a727d2b589e10cd64666c..d9c3e20a728a88c3cc8fcb0a966ec7b96e0443b1 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Cargo',
     'cover_image' => 'Imagem de capa',
     'cover_image_description' => 'Esta imagem deve ser aproximadamente 440x250px.',
-    
+
     // Actions
     'actions' => 'Ações',
     'view' => 'Visualizar',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Redefinir',
     'remove' => 'Remover',
     'add' => 'Adicionar',
+    'configure' => 'Configurar',
     'fullscreen' => 'Ecrã completo',
+    'favourite' => 'Favorito',
+    'unfavourite' => 'Retirar Favorito',
+    'next' => 'Próximo',
+    'previous' => 'Anterior',
+    'filter_active' => 'Filtro Ativo:',
+    'filter_clear' => 'Limpar Filtro',
 
     // Sort Options
     'sort_options' => 'Opções de Ordenação',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Nenhuma atividade a mostrar',
     'no_items' => 'Nenhum item disponível',
     'back_to_top' => 'Voltar ao topo',
+    'skip_to_main_content' => 'Avançar para o conteúdo principal',
     'toggle_details' => 'Alternar Detalhes',
     'toggle_thumbnails' => 'Alternar Miniaturas',
     'details' => 'Detalhes',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Visualização em Lista',
     'default' => 'Padrão',
     'breadcrumb' => 'Caminho',
+    'status' => 'Estado',
+    'status_active' => 'Ativo',
+    'status_inactive' => 'Inativo',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Expandir Menu de Cabeçalho',
     'profile_menu' => 'Menu de Perfil',
     'view_profile' => 'Visualizar Perfil',
     'edit_profile' => 'Editar Perfil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informações',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Separador: Mostrar Informação Secundária',
     'tab_content' => 'Conteúdo',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Separador: Mostrar Conteúdo Primário',
 
     // Email Content
     'email_action_help' => 'Se estiver com problemas ao carregar no botão ":actionText", copie e cole o URL abaixo no seu navegador:',
index 82ac03aa500fab6741814315c79d6ee2088903be..5f2058dfd268f2f1a5e3da1d5a887f81f54586db 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Imagens',
     'my_recent_drafts' => 'Os Meus Rascunhos Recentes',
     'my_recently_viewed' => 'Visualizados Recentemente Por Mim',
+    'my_most_viewed_favourites' => 'Os Meus Favoritos Mais Visualizados',
+    'my_favourites' => 'Os Meus Favoritos',
     'no_pages_viewed' => 'Você não viu nenhuma página',
     'no_pages_recently_created' => 'Nenhuma página foi recentemente criada',
     'no_pages_recently_updated' => 'Nenhuma página foi recentemente atualizada',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Arquivo Web contido',
     'export_pdf' => 'Arquivo PDF',
     'export_text' => 'Arquivo Texto',
+    'export_md' => 'Ficheiro Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Permissões',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permissões da Estante',
     'shelves_permissions_updated' => 'Permissões da Estante de Livros Atualizada',
     'shelves_permissions_active' => 'Permissões da Estante de Livros Ativas',
+    'shelves_permissions_cascade_warning' => 'As permissões nas estantes não são passadas automaticamente em efeito dominó para os livros contidos. Isto acontece porque um livro pode existir em várias prateleiras. As permissões podem, no entanto, ser copiadas para livros filhos usando a opção encontrada abaixo.',
     'shelves_copy_permissions_to_books' => 'Copiar Permissões para Livros',
     'shelves_copy_permissions' => 'Copiar Permissões',
     'shelves_copy_permissions_explain' => 'Isto aplicará as configurações de permissões atuais desta estante a todos os livros nela contidos. Antes de ativar, assegure-se de que quaisquer alterações nas permissões desta estante foram guardadas.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Capítulos por Último',
     'books_sort_show_other' => 'Mostrar Outros Livros',
     'books_sort_save' => 'Guardar Nova Ordenação',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Capítulo',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Mover Capítulo',
     'chapters_move_named' => 'Mover Capítulo :chapterName',
     'chapter_move_success' => 'Capítulo movido para :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Permissões do Capítulo',
     'chapters_empty' => 'Nenhuma página existente neste capítulo.',
     'chapters_permissions_active' => 'Permissões de Capítulo Ativas',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nova Página',
     'pages_editing_draft_notification' => 'Você está atualmente a editar um rascunho que foi guardado pela última vez a :timeDiff.',
     'pages_draft_edited_notification' => 'Esta página entretanto já foi atualizada. É recomendado que você descarte este rascunho.',
+    'pages_draft_page_changed_since_creation' => 'Esta página foi atualizada desde que este rascunho foi criado. É recomendável que descarte este rascunho ou tenha cuidado para não sobrescrever nenhuma alteração de página.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuários iniciaram a edição dessa página',
         'start_b' => ':userName iniciou a edição desta página',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Adicione algumas etiquetas para melhor categorizar o seu conteúdo. \n Você poderá atribuir valores às etiquetas para uma organização mais complexa.",
     'tags_add' => 'Adicionar outra etiqueta',
     'tags_remove' => 'Remover esta etiqueta',
+    'tags_usages' => 'Total de marcadores usados',
+    'tags_assigned_pages' => 'Atribuído às páginas',
+    'tags_assigned_chapters' => 'Atribuído aos Capítulos',
+    'tags_assigned_books' => 'Atribuído a Livros',
+    'tags_assigned_shelves' => 'Atribuído a Prateleiras',
+    'tags_x_unique_values' => ':count valores únicos',
+    'tags_all_values' => 'Todos os valores',
+    'tags_view_tags' => 'Ver Marcadores',
+    'tags_view_existing_tags' => 'Ver marcadores existentes',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Anexos',
     'attachments_explain' => 'Carregue alguns arquivos ou anexe links para serem exibidos na sua página. Eles estarão visíveis na barra lateral à direita.',
     'attachments_explain_instant_save' => 'As mudanças são guardadas instantaneamente.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Tem a certeza de que deseja eliminar esta revisão?',
     'revision_restore_confirm' => 'Tem a certeza que deseja restaurar esta revisão? O conteúdo atual da página será substituído.',
     'revision_delete_success' => 'Revisão excluída',
-    'revision_cannot_delete_latest' => 'Não é possível eliminar a revisão mais recente.'
+    'revision_cannot_delete_latest' => 'Não é possível eliminar a revisão mais recente.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index d1f3d8a5f4b928a651e846ee7cc1f1ee6f9f4cba..93b06ad3dfbc0e1737f22fa00214f339d57ac6b4 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Não foi possível encontrar um endereço de e-mail para este utilizador nos dados providenciados pelo sistema de autenticação externa',
     'saml_invalid_response_id' => 'A requisição do sistema de autenticação externa não foi reconhecia por um processo iniciado por esta aplicação. Navegar para o caminho anterior após o inicio de sessão pode provocar este problema.',
     'saml_fail_authed' => 'Inicio de sessão com :system falhou. O sistema não forneceu uma autorização bem sucedida',
+    'oidc_already_logged_in' => 'Sessão já iniciada',
+    'oidc_user_not_registered' => 'O utilizador :name não está registado e o registo automático está desativado',
+    'oidc_no_email_address' => 'Não foi possível encontrar um endereço de e-mail para este utilizador nos dados providenciados pelo sistema de autenticação externo',
+    'oidc_fail_authed' => 'Inicio de sessão com :system falhou. O sistema não forneceu uma autorização bem sucedida',
     'social_no_action_defined' => 'Nenhuma ação definida',
     'social_login_bad_response' => "Erro recebido durante o inicio de sessão :socialAccount: \n:error",
     'social_account_in_use' => 'Esta conta :socialAccount já está em uso. Por favor, tente entrar utilizando a opção :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Página Não Encontrada',
     'sorry_page_not_found' => 'Desculpe, a página que procura não foi encontrada.',
     'sorry_page_not_found_permission_warning' => 'Se esperava que esta página existisse, talvez não tenha permissão para visualizá-la.',
+    'image_not_found' => 'Imagem não encontrada',
+    'image_not_found_subtitle' => 'Desculpe, o arquivo de imagem que estava à procura não foi encontrado.',
+    'image_not_found_details' => 'Se estava à espera que a mesma existisse é possível que tenha sido eliminada.',
     'return_home' => 'Regressar à página inicial',
     'error_occurred' => 'Ocorreu um Erro',
     'app_down' => ':appName está fora do ar de momento',
index 3222d218dbd9b0e6754db139659c218547974843..edcd9c8dcc58af8d66bce37912f79d955699f0e6 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Manutenção',
     'maint_image_cleanup' => 'Limpeza de Imagens',
-    'maint_image_cleanup_desc' => "Examina páginas e reviste os seus conteúdos para verificar quais imagens e desenhos estão atualmente em uso e quais são redundantes. Certifique-se de criar uma cópia de segurança completa da base de dados e imagens antes de executar esta ação.",
+    'maint_image_cleanup_desc' => 'Examina páginas e reviste os seus conteúdos para verificar quais imagens e desenhos estão atualmente em uso e quais são redundantes. Certifique-se de criar uma cópia de segurança completa da base de dados e imagens antes de executar esta ação.',
     'maint_delete_images_only_in_revisions' => 'Eliminar também imagens que existam apenas em revisões de página antigas',
     'maint_image_cleanup_run' => 'Executar Limpeza',
     'maint_image_cleanup_warning' => ':count imagens potencialmente não utilizadas foram encontradas. Tem certeza de que deseja eliminar estas imagens?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Reciclagem',
     'recycle_bin_desc' => 'Aqui pode restaurar itens que foram eliminados ou eliminá-los permanentemente do sistema. Esta lista não é filtrada diferentemente de listas de atividades parecidas no sistema onde filtros de permissão são aplicados.',
     'recycle_bin_deleted_item' => 'Item eliminado',
+    'recycle_bin_deleted_parent' => 'Parente',
     'recycle_bin_deleted_by' => 'Eliminado por',
     'recycle_bin_deleted_at' => 'Data de Eliminação',
     'recycle_bin_permanently_delete' => 'Eliminar permanentemente',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Itens a serem Restaurados',
     'recycle_bin_restore_confirm' => 'Esta ação irá restaurar o item excluído, inclusive quaisquer elementos filhos, para o seu local original. Se a localização original tiver, entretanto, sido eliminada e estiver agora na reciclagem, o item pai também precisará de ser restaurado.',
     'recycle_bin_restore_deleted_parent' => 'O parente deste item foi também eliminado. Estes permanecerão eliminados até que o parente seja também restaurado.',
+    'recycle_bin_restore_parent' => 'Restaurar Parente',
     'recycle_bin_destroy_notification' => 'Eliminados no total :count itens da lixeira.',
     'recycle_bin_restore_notification' => 'Restaurados no total :count itens da reciclagem.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Utilizador',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Item ou Detalhe Relacionado',
+    'audit_table_ip' => 'Endereço de IP',
     'audit_table_date' => 'Data da Atividade',
     'audit_date_from' => 'Intervalo De',
     'audit_date_to' => 'Intervalo Até',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Detalhes do Cargo',
     'role_name' => 'Nome do Cargo',
     'role_desc' => 'Breve Descrição do Cargo',
+    'role_mfa_enforced' => 'Exige autenticação de múltiplos fatores',
     'role_external_auth_id' => 'IDs de Autenticação Externa',
     'role_system' => 'Permissões do Sistema',
     'role_manage_users' => 'Gerir utilizadores',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gerir modelos de página',
     'role_access_api' => 'Aceder à API do sistema',
     'role_manage_settings' => 'Gerir as configurações da aplicação',
+    'role_export_content' => 'Exportar conteúdo',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Cargos do Utilizador',
     'users_role_desc' => 'Selecione os cargos aos quais este utilizador será vinculado. Se um utilizador for vinculado a múltiplos cargos, as suas permissões serão empilhadas e ele receberá todas as habilidades dos cargos atribuídos.',
     'users_password' => 'Palavra-passe do Utilizador',
-    'users_password_desc' => 'Defina uma palavra-passe utilizada para efetuar a autenticação na aplicação. Esta deve ter pelo menos 6 caracteres.',
+    'users_password_desc' => 'Defina uma palavra-passe para efetuar a autenticação na aplicação. Esta deve ter pelo menos 8 caracteres.',
     'users_send_invite_text' => 'Pode escolher enviar a este utilizador um convite por e-mail que o possibilitará definir a sua própria palavra-passe, ou defina você mesmo uma.',
     'users_send_invite_option' => 'Enviar convite por e-mail',
     'users_external_auth_id' => 'ID de Autenticação Externa',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Criar Token',
     'users_api_tokens_expires' => 'Expira',
     'users_api_tokens_docs' => 'Documentação da API',
+    'users_mfa' => 'Autenticação Multi-fator',
+    'users_mfa_desc' => 'Configure a autenticação multi-fatores como uma camada extra de segurança para sua conta de utilizador.',
+    'users_mfa_x_methods' => ':count método configurado|:count métodos configurados',
+    'users_mfa_configure' => 'Configurar Métodos',
 
     // API Tokens
     'user_api_token_create' => 'Criar Token de API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Tem certeza que deseja eliminar este token de API?',
     'user_api_token_delete_success' => 'Token de API excluído com sucesso',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Criar um novo webhook',
+    'webhooks_none_created' => 'Ainda nenhum webhooks foi criado.',
+    'webhooks_edit' => 'Editar Webhook',
+    'webhooks_save' => 'Guardar Webhook',
+    'webhooks_details' => 'Detalhes do Webhook',
+    'webhooks_details_desc' => 'Providencie um nome fácil e um endpoint POST para onde os dados do webhook serão enviados.',
+    'webhooks_events' => 'Eventos de Webhook',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'Todos os eventos do sistema',
+    'webhooks_name' => 'Nome do Webhook',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Endpoint do Webhook',
+    'webhooks_active' => 'Webhook ativo',
+    'webhook_events_table_header' => 'Eventos',
+    'webhooks_delete' => 'Eliminar Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Tem a certeza que deseja eliminar este webhook?',
+    'webhooks_format_example' => 'Exemplo de formato Webhook',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index b36ed0dab7b83a508da381c7b652cade9cad1664..b5a15ed363016e9212e6d14fa769b075097ffa76 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'O campo :attribute deve conter apenas letras, números, traços e sublinhado.',
     'alpha_num'            => 'O campo :attribute deve conter apenas letras e números.',
     'array'                => 'O campo :attribute deve ser uma lista(array).',
+    'backup_codes'         => 'O código fornecido não é válido ou já foi utilizado.',
     'before'               => 'O campo :attribute deve ser uma data anterior à data :date.',
     'between'              => [
         'numeric' => 'O campo :attribute deve estar entre :min e :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'O campo :attribute deve ser uma string.',
     'timezone'             => 'O campo :attribute deve conter uma timezone válida.',
+    'totp'                 => 'O código fornecido não é válido ou já expirou.',
     'unique'               => 'Já existe um campo/dado de nome :attribute.',
     'url'                  => 'O formato da URL :attribute é inválido.',
     'uploaded'             => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.',
index 33781417f755840ae0819234d2f065e2c03374e1..f6fa8e415c1d090318b2d913f01b4ced09e34b31 100644 (file)
@@ -22,7 +22,7 @@ return [
     'chapter_update'              => 'atualizou o capítulo',
     'chapter_update_notification' => 'Capítulo atualizado com sucesso',
     'chapter_delete'              => 'excluiu o capítulo',
-    'chapter_delete_notification' => 'Capítulo excluído com sucesso',
+    'chapter_delete_notification' => 'Capítulo excluída com sucesso',
     'chapter_move'                => 'moveu o capítulo',
 
     // Books
@@ -36,13 +36,29 @@ return [
     'book_sort_notification'      => 'Livro reordenado com sucesso',
 
     // Bookshelves
-    'bookshelf_create'            => 'criou a prateleira',
+    'bookshelf_create'            => 'prateleira criada',
     'bookshelf_create_notification'    => 'Prateleira criada com sucesso',
     'bookshelf_update'                 => 'atualizou a prateleira',
     'bookshelf_update_notification'    => 'Prateleira atualizada com sucesso',
     'bookshelf_delete'                 => 'excluiu a prateleira',
     'bookshelf_delete_notification'    => 'Prateleira excluída com sucesso',
 
+    // Favourites
+    'favourite_add_notification' => '":name" foi adicionada aos seus favoritos',
+    'favourite_remove_notification' => '":name" foi removida dos seus favoritos',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Método de multi-fatores configurado com sucesso',
+    'mfa_remove_method_notification' => 'Método de multi-fatores removido com sucesso',
+
+    // Webhooks
+    'webhook_create' => 'webhook criado',
+    'webhook_create_notification' => 'Webhook criado com sucesso',
+    'webhook_update' => 'webhook atualizado',
+    'webhook_update_notification' => 'Webhook atualizado com sucesso',
+    'webhook_delete' => 'webhook excluído',
+    'webhook_delete_notification' => 'Webhook excluido com sucesso',
+
     // Other
     'commented_on'                => 'comentou em',
     'permissions_update'          => 'atualizou permissões',
index b2c3072c41a8fe6af821064239c6ddc1511ae895..d9374296dc0de6ade87ffa2270f25776553d0570 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Senha',
     'password_confirm' => 'Confirmar Senha',
-    'password_hint' => 'Deve ser maior que 7 caracteres',
+    'password_hint' => 'Deve conter pelo menos 8 caracteres',
     'forgot_password' => 'Esqueceu a senha?',
     'remember_me' => 'Lembrar de mim',
     'ldap_email_hint' => 'Por favor, digite um e-mail para essa conta.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'O domínio de e-mail usado não tem acesso permitido a essa aplicação',
     'register_success' => 'Obrigado por se cadastrar! Você agora encontra-se cadastrado(a) e logado(a).',
 
-
     // Password Reset
     'reset_password' => 'Redefinir Senha',
     'reset_password_send_instructions' => 'Insira seu e-mail abaixo e uma mensagem com o link de redefinição de senha lhe será enviada.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Você recebeu esse e-mail pois recebemos uma solicitação de redefinição de senha para a sua conta.',
     'email_reset_not_requested' => 'Caso não tenha sido você a solicitar a redefinição de senha, ignore esse e-mail.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirme seu e-mail para :appName',
     'email_confirm_greeting' => 'Obrigado por se cadastrar em :appName!',
     'email_confirm_text' => 'Por favor, confirme seu endereço de e-mail clicando no botão abaixo:',
     'email_confirm_action' => 'Confirmar E-mail',
     'email_confirm_send_error' => 'A confirmação de e-mail é requerida, mas o sistema não pôde enviar a mensagem. Por favor, entre em contato com o administrador para se certificar que o serviço de envio de e-mails está corretamente configurado.',
-    'email_confirm_success' => 'Seu e-mail foi confirmado!',
+    'email_confirm_success' => 'Seu e-mail foi confirmado! Agora você pode de entrar usando este endereço de e-mail.',
     'email_confirm_resent' => 'E-mail de confirmação reenviado. Por favor, verifique sua caixa de entrada.',
 
     'email_not_confirmed' => 'Endereço de E-mail Não Confirmado',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Bem-vindo(a) a :appName!',
     'user_invite_page_text' => 'Para finalizar sua conta e obter acesso, você precisa definir uma senha que será usada para efetuar login em :appName em futuras visitas.',
     'user_invite_page_confirm_button' => 'Confirmar Senha',
-    'user_invite_success' => 'Senha definida, você agora tem acesso a :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Senha definida, agora você pode fazer login usando sua senha para acessar :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Configurar autenticação multi-fator',
+    'mfa_setup_desc' => 'A autenticação multi-fator adiciona outra camada de segurança à sua conta.',
+    'mfa_setup_configured' => 'Configurado',
+    'mfa_setup_reconfigure' => 'Reconfigurar',
+    'mfa_setup_remove_confirmation' => 'Você tem certeza que deseja remover o método de autenticação de vários fatores?',
+    'mfa_setup_action' => 'Configurações',
+    'mfa_backup_codes_usage_limit_warning' => 'Você tem menos de 5 códigos de backup restantes, Por favor, gere e armazene um novo conjunto antes de esgotar suas opções de códigos de backup para evitar estar bloqueado para fora da sua conta.',
+    'mfa_option_totp_title' => 'Aplicativo Móvel',
+    'mfa_option_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Google Authenticator, Authy ou o Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Códigos de backup',
+    'mfa_option_backup_codes_desc' => 'Armazene com segurança um conjunto de códigos de backup únicos que você pode inserir para verificar sua identidade.',
+    'mfa_gen_confirm_and_enable' => 'Confirmar e habilitar',
+    'mfa_gen_backup_codes_title' => 'Configuração dos Códigos de Backup',
+    'mfa_gen_backup_codes_desc' => 'Armazene a lista de códigos abaixo em um lugar seguro. Ao acessar o sistema você poderá usar um dos códigos como segundo mecanismo de autenticação.',
+    'mfa_gen_backup_codes_download' => 'Baixar códigos',
+    'mfa_gen_backup_codes_usage_warning' => 'Cada código só poderá ser usado uma vez',
+    'mfa_gen_totp_title' => 'Configuração de Aplicativos Móveis',
+    'mfa_gen_totp_desc' => 'Para usar a autenticação multi-fator, você precisará de um aplicativo móvel que suporte TOTP como o Google Authenticator, Authy ou o Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Leia o código QR abaixo usando o aplicativo de autenticação de sua preferência para começar.',
+    'mfa_gen_totp_verify_setup' => 'Verificar configuração',
+    'mfa_gen_totp_verify_setup_desc' => 'Verifique se tudo está funcionando digitando um código, gerado dentro do seu aplicativo de autenticação, na caixa de entrada abaixo:',
+    'mfa_gen_totp_provide_code_here' => 'Insira o código gerado pelo aplicativo aqui',
+    'mfa_verify_access' => 'Verificar Acesso',
+    'mfa_verify_access_desc' => 'Sua conta de usuário requer que você confirme sua identidade por meio de um nível adicional de verificação antes de conceder o acesso. Verifique o uso de um dos métodos configurados para continuar.',
+    'mfa_verify_no_methods' => 'Nenhum método configurado',
+    'mfa_verify_no_methods_desc' => 'Nenhum método de autenticação multi-fator foi encontrado em sua conta. Você precisará configurar pelo menos um método antes de ter acesso.',
+    'mfa_verify_use_totp' => 'Verificar usando um aplicativo móvel',
+    'mfa_verify_use_backup_codes' => 'Verificar usando um código de backup',
+    'mfa_verify_backup_code' => 'Código de backup',
+    'mfa_verify_backup_code_desc' => 'Insira um dos seus códigos de backup restantes abaixo:',
+    'mfa_verify_backup_code_enter_here' => 'Digite o código de backup',
+    'mfa_verify_totp_desc' => 'Digite o código, gerado através do seu aplicativo móvel, abaixo:',
+    'mfa_setup_login_notification' => 'Método de multi-fatores configurado, por favor faça login novamente usando o método configurado.',
+];
index d2d544901179d61d0e846f3845a2627f22c905d2..d9f54d8a0ae8611c2b329d31d9d3fcc6ddd69e58 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Cargo',
     'cover_image' => 'Imagem de capa',
     'cover_image_description' => 'Esta imagem deve ser aproximadamente 440x250px.',
-    
+
     // Actions
     'actions' => 'Ações',
     'view' => 'Visualizar',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Redefinir',
     'remove' => 'Remover',
     'add' => 'Adicionar',
+    'configure' => 'Configurar',
     'fullscreen' => 'Tela cheia',
+    'favourite' => 'Favoritos',
+    'unfavourite' => 'Remover dos Favoritos',
+    'next' => 'Seguinte',
+    'previous' => 'Anterior',
+    'filter_active' => 'Filtro Ativo:',
+    'filter_clear' => 'Limpar Filtro',
 
     // Sort Options
     'sort_options' => 'Opções de Ordenação',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Nenhuma atividade a mostrar',
     'no_items' => 'Nenhum item disponível',
     'back_to_top' => 'Voltar ao topo',
+    'skip_to_main_content' => 'Ir para o conteúdo principal',
     'toggle_details' => 'Alternar Detalhes',
     'toggle_thumbnails' => 'Alternar Miniaturas',
     'details' => 'Detalhes',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Visualização em Lista',
     'default' => 'Padrão',
     'breadcrumb' => 'Caminho',
+    'status' => 'Status',
+    'status_active' => 'Ativo',
+    'status_inactive' => 'Inativo',
+    'never' => 'Nunca',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Expandir Cabeçalho do Menu',
     'profile_menu' => 'Menu de Perfil',
     'view_profile' => 'Visualizar Perfil',
     'edit_profile' => 'Editar Perfil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informações',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Aba: Mostrar Informação Secundária',
     'tab_content' => 'Conteúdo',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Aba: Mostrar Conteúdo Primário',
 
     // Email Content
     'email_action_help' => 'Se você estiver tendo problemas ao clicar o botão ":actionText", copie e cole a URL abaixo no seu navegador:',
index e67dfc8c37b7ba5819cb4ed5a5cc715203429953..e28c97f9e4948af3e48d018469667ac892026bd6 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Imagens',
     'my_recent_drafts' => 'Meus Rascunhos Recentes',
     'my_recently_viewed' => 'Visualizados por mim Recentemente',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'Você não visualizou nenhuma página',
     'no_pages_recently_created' => 'Nenhuma página criada recentemente',
     'no_pages_recently_updated' => 'Nenhuma página atualizada recentemente',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Arquivo Web Contained',
     'export_pdf' => 'Arquivo PDF',
     'export_text' => 'Arquivo Texto',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Permissões',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Permissões da Prateleira',
     'shelves_permissions_updated' => 'Permissões da Prateleira Atualizadas',
     'shelves_permissions_active' => 'Permissões da Prateleira Ativas',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Copiar Permissões para Livros',
     'shelves_copy_permissions' => 'Copiar Permissões',
     'shelves_copy_permissions_explain' => 'Isto aplicará as configurações de permissões atuais desta prateleira a todos os livros contidos nela. Antes de ativar, assegure-se de que quaisquer alterações nas permissões desta prateleira tenham sido salvas.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Capítulos por Último',
     'books_sort_show_other' => 'Mostrar Outros Livros',
     'books_sort_save' => 'Salvar Nova Ordenação',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Capítulo',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Mover Capítulo',
     'chapters_move_named' => 'Mover Capítulo :chapterName',
     'chapter_move_success' => 'Capítulo movido para :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Permissões do Capítulo',
     'chapters_empty' => 'Nenhuma página existente nesse capítulo.',
     'chapters_permissions_active' => 'Permissões de Capítulo Ativas',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nova Página',
     'pages_editing_draft_notification' => 'Você está atualmente editando um rascunho que foi salvo da última vez em :timeDiff.',
     'pages_draft_edited_notification' => 'Essa página foi atualizada desde então. É recomendado que você descarte esse rascunho.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count usuários iniciaram a edição dessa página',
         'start_b' => ':userName iniciou a edição dessa página',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Adicione algumas tags para melhor categorizar seu conteúdo. \n Você pode atribuir valores às tags para uma organização mais complexa.",
     'tags_add' => 'Adicionar outra tag',
     'tags_remove' => 'Remover essa tag',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Anexos',
     'attachments_explain' => 'Faça o upload de alguns arquivos ou anexe links para serem exibidos na sua página. Eles estarão visíveis na barra lateral à direita.',
     'attachments_explain_instant_save' => 'Mudanças são salvas instantaneamente.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Tem certeza de que deseja excluir esta revisão?',
     'revision_restore_confirm' => 'Tem certeza que deseja restaurar esta revisão? O conteúdo atual da página será substituído.',
     'revision_delete_success' => 'Revisão excluída',
-    'revision_cannot_delete_latest' => 'Não é possível excluir a revisão mais recente.'
+    'revision_cannot_delete_latest' => 'Não é possível excluir a revisão mais recente.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index c65d7f4969e14573eec31203e3f45a2dc955a0c8..63928f594dc695ef6ffbf5f2323b0df8bb286ad1 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Não foi possível encontrar um endereço de e-mail para este usuário nos dados providos pelo sistema de autenticação externa',
     'saml_invalid_response_id' => 'A requisição do sistema de autenticação externa não foi reconhecia por um processo iniciado por esta aplicação. Após o login, navegar para o caminho anterior pode causar um problema.',
     'saml_fail_authed' => 'Login utilizando :system falhou. Sistema não forneceu autorização bem sucedida',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Nenhuma ação definida',
     'social_login_bad_response' => "Erro recebido durante o login :socialAccount: \n:error",
     'social_account_in_use' => 'Essa conta :socialAccount já está em uso. Por favor, tente entrar utilizando a opção :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Página Não Encontrada',
     'sorry_page_not_found' => 'Desculpe, a página que você está procurando não pôde ser encontrada.',
     'sorry_page_not_found_permission_warning' => 'Se você esperava que esta página existisse, talvez você não tenha permissão para visualizá-la.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Retornar à página inicial',
     'error_occurred' => 'Ocorreu um Erro',
     'app_down' => ':appName está fora do ar no momento',
index 6e801e27d4d1e86bc6525ae2398bc4750b8141c3..e6c1139ccba9970c3ba916d0924ea3a480f6afcd 100644 (file)
@@ -38,7 +38,7 @@ return [
     '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_select' => 'Selecione uma página',
     'app_footer_links' => 'Links do Rodapé',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
+    '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::<key>" 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".',
     'app_footer_links_label' => 'Etiqueta do Link',
     'app_footer_links_url' => 'URL do Link',
     'app_footer_links_add' => 'Adicionar Link de Rodapé',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Manutenção',
     'maint_image_cleanup' => 'Limpeza de Imagens',
-    'maint_image_cleanup_desc' => "Examina páginas e revisa seus conteúdos para verificar quais imagens e desenhos estão atualmente em uso e quais são redundantes. Certifique-se de criar um backup completo do banco de dados e imagens antes de executar esta ação.",
+    'maint_image_cleanup_desc' => 'Examina páginas e revisa seus conteúdos para verificar quais imagens e desenhos estão atualmente em uso e quais são redundantes. Certifique-se de criar um backup completo do banco de dados e imagens antes de executar esta ação.',
     'maint_delete_images_only_in_revisions' => 'Também excluir imagens que existem apenas em revisões de página antigas',
     'maint_image_cleanup_run' => 'Executar Limpeza',
     'maint_image_cleanup_warning' => ':count imagens potencialmente não utilizadas foram encontradas. Tem certeza de que deseja excluir estas imagens?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Lixeira',
     'recycle_bin_desc' => 'Aqui você pode restaurar itens que foram excluídos ou escolher removê-los permanentemente do sistema. Esta lista não é filtrada diferentemente de listas de atividades similares no sistema onde filtros de permissão são aplicados.',
     'recycle_bin_deleted_item' => 'Item excluído',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Excluído por',
     'recycle_bin_deleted_at' => 'Momento de Exclusão',
     'recycle_bin_permanently_delete' => 'Excluir permanentemente',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Itens a serem restaurados',
     'recycle_bin_restore_confirm' => 'Esta ação irá restaurar o item excluído, inclusive quaisquer elementos filhos, para seu local original. Se a localização original tiver, entretanto, sido eliminada e estiver agora na lixeira, o item pai também precisará ser restaurado.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Usuário',
     'audit_table_event' => 'Evento',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Data da Atividade',
     'audit_date_from' => 'Date Range From',
     'audit_date_to' => 'Date Range To',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Detalhes do Cargo',
     'role_name' => 'Nome do Cargo',
     'role_desc' => 'Breve Descrição do Cargo',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'IDs de Autenticação Externa',
     'role_system' => 'Permissões do Sistema',
     'role_manage_users' => 'Gerenciar usuários',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Gerenciar modelos de página',
     'role_access_api' => 'Acessar API do sistema',
     'role_manage_settings' => 'Gerenciar configurações da aplicação',
+    'role_export_content' => 'Export content',
     '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 cargos 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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Cargos do Usuário',
     'users_role_desc' => 'Selecione os cargos aos quais este usuário será vinculado. Se um usuário for vinculado a múltiplos cargos, suas permissões serão empilhadas e ele receberá todas as habilidades dos cargos atribuídos.',
     'users_password' => 'Senha do Usuário',
-    'users_password_desc' => 'Defina uma senha usada para fazer login na aplicação. Esta deve ter pelo menos 6 caracteres.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Você pode escolher enviar a este usuário um convite por e-mail que o possibilitará definir sua própria senha, ou defina você uma senha.',
     'users_send_invite_option' => 'Enviar convite por e-mail',
     'users_external_auth_id' => 'ID de Autenticação Externa',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Criar Token',
     'users_api_tokens_expires' => 'Expira',
     'users_api_tokens_docs' => 'Documentação da API',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Criar Token de API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Você tem certeza que deseja excluir este token de API?',
     'user_api_token_delete_success' => 'Token de API excluído com sucesso',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index ea3779c78874efda0f86b0c0ae69b2895eec1bd1..705534cb6d5d67ce8b63f7d399fc68a5622a3072 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'O campo :attribute deve conter apenas letras, números, traços e underlines.',
     'alpha_num'            => 'O campo :attribute deve conter apenas letras e números.',
     'array'                => 'O campo :attribute deve ser uma array.',
+    'backup_codes'         => 'O código fornecido não é válido ou já foi usado.',
     'before'               => 'O campo :attribute deve ser uma data anterior à data :date.',
     'between'              => [
         'numeric' => 'O campo :attribute deve estar entre :min e :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'O campo :attribute deve ser uma string.',
     'timezone'             => 'O campo :attribute deve conter uma timezone válida.',
+    'totp'                 => 'O código fornecido não é válido ou expirou.',
     'unique'               => 'Já existe um campo/dado de nome :attribute.',
     'url'                  => 'O formato da URL :attribute é inválido.',
     'uploaded'             => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.',
index af5a7af1d93fc7e8d8c4a91ccf593c5fc854fa22..9970db51f70455720800d13338c8df2cabe51616 100644 (file)
@@ -43,6 +43,22 @@ return [
     'bookshelf_delete'                 => 'удалил полку',
     'bookshelf_delete_notification'    => 'Полка успешно удалена',
 
+    // Favourites
+    'favourite_add_notification' => '":name" добавлено в избранное',
+    'favourite_remove_notification' => '":name" удалено из избранного',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Двухфакторный метод авторизации успешно настроен',
+    'mfa_remove_method_notification' => 'Двухфакторный метод авторизации успешно удален',
+
+    // Webhooks
+    'webhook_create' => 'создал вебхук',
+    'webhook_create_notification' => 'Вебхук успешно создан',
+    'webhook_update' => 'обновил вебхук',
+    'webhook_update_notification' => 'Вебхук успешно обновлен',
+    'webhook_delete' => 'удалил вебхук',
+    'webhook_delete_notification' => 'Вебхук успешно удален',
+
     // Other
     'commented_on'                => 'прокомментировал',
     'permissions_update'          => 'обновил разрешения',
index 1f0ec6b802d707e4f41c16f3120619bfce72f6a1..653b5ac81a4d54c61930f4336fc07059acad0c35 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Адрес электронной почты',
     'password' => 'Пароль',
     'password_confirm' => 'Подтверждение пароля',
-    'password_hint' => 'Ð\9cинимÑ\83м 8 символов',
+    'password_hint' => 'Ð\9dе Ð¼ÐµÐ½ÐµÐµ 8 символов',
     'forgot_password' => 'Забыли пароль?',
     'remember_me' => 'Запомнить меня',
     'ldap_email_hint' => 'Введите адрес электронной почты для этой учетной записи.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Данный домен электронной почты недоступен для регистрации',
     'register_success' => 'Спасибо за регистрацию! Регистрация и вход в систему выполнены.',
 
-
     // Password Reset
     'reset_password' => 'Сброс пароля',
     'reset_password_send_instructions' => 'Введите свой адрес электронной почты ниже, и вам будет отправлено письмо со ссылкой для сброса пароля.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Вы получили это письмо, потому что запросили сброс пароля для вашей учетной записи.',
     'email_reset_not_requested' => 'Если вы не запрашивали сброса пароля, то никаких дополнительных действий не требуется.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Подтвердите ваш почтовый адрес на :appName',
     'email_confirm_greeting' => 'Благодарим за участие :appName!',
     'email_confirm_text' => 'Пожалуйста, подтвердите свой адрес электронной почты нажав на кнопку ниже:',
     'email_confirm_action' => 'Подтвердить адрес электронной почты',
     'email_confirm_send_error' => 'Требуется подтверждение электронной почты, но система не может отправить письмо. Свяжитесь с администратором, чтобы убедиться, что адрес электронной почты настроен правильно.',
-    'email_confirm_success' => 'Ваш адрес подтвержден!',
+    'email_confirm_success' => 'Ваш адрес электронной почты был подтвержден! Теперь вы можете войти в систему, используя этот адрес электронной почты.',
     'email_confirm_resent' => 'Письмо с подтверждение выслано снова. Пожалуйста, проверьте ваш почтовый ящик.',
 
     'email_not_confirmed' => 'Адрес электронной почты не подтвержден',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Добро пожаловать в :appName!',
     'user_invite_page_text' => 'Завершите настройку аккаунта, установите пароль для дальнейшего входа в :appName.',
     'user_invite_page_confirm_button' => 'Подтвердите пароль',
-    'user_invite_success' => 'Пароль установлен, теперь у вас есть доступ к :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Пароль установлен, теперь вы можете войти в систему, используя установленный пароль для доступа к :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Двухфакторная аутентификация',
+    'mfa_setup_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
+    'mfa_setup_configured' => 'Настроено',
+    'mfa_setup_reconfigure' => 'Перенастроить',
+    'mfa_setup_remove_confirmation' => 'Вы уверены, что хотите удалить этот двухфакторный метод аутентификации?',
+    'mfa_setup_action' => 'Настройка',
+    'mfa_backup_codes_usage_limit_warning' => 'У вас осталось менее 5 резервных кодов, пожалуйста, создайте и сохраните новый набор перед тем, как закончатся коды, чтобы предотвратить блокировку вашей учетной записи.',
+    'mfa_option_totp_title' => 'Мобильное приложение',
+    'mfa_option_totp_desc' => 'Для использования двухфакторной аутентификации вам понадобится мобильное приложение, поддерживающее TOTP, например Google Authenticator, Authy или Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Резервные коды',
+    'mfa_option_backup_codes_desc' => 'Безопасно хранить набор одноразовых резервных кодов, которые вы можете ввести для проверки вашей личности.',
+    'mfa_gen_confirm_and_enable' => 'Подтвердить и включить',
+    'mfa_gen_backup_codes_title' => 'Настройка резервных кодов',
+    'mfa_gen_backup_codes_desc' => 'Сохраните приведенный ниже список кодов в безопасном месте. При доступе к системе вы сможете использовать один из кодов в качестве второго механизма аутентификации.',
+    'mfa_gen_backup_codes_download' => 'Скачать коды',
+    'mfa_gen_backup_codes_usage_warning' => 'Каждый код может быть использован только один раз',
+    'mfa_gen_totp_title' => 'Настройка мобильного приложения',
+    'mfa_gen_totp_desc' => 'Для использования двухфакторной аутентификации вам понадобится мобильное приложение, поддерживающее TOTP, например Google Authenticator, Authy или Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Отсканируйте QR-код, используя приложение для аутентификации.',
+    'mfa_gen_totp_verify_setup' => 'Проверить настройки',
+    'mfa_gen_totp_verify_setup_desc' => 'Проверьте, что все работает введя код, сгенерированный внутри вашего приложения для аутентификации, в поле ввода ниже:',
+    'mfa_gen_totp_provide_code_here' => 'Введите код, сгенерированный приложением',
+    'mfa_verify_access' => 'Подтвердите доступ',
+    'mfa_verify_access_desc' => 'Ваша учетная запись требует подтверждения личности на дополнительном уровне верификации, прежде чем вам будет предоставлен доступ. Для продолжения подтвердите вход, используя один из настроенных методов.',
+    'mfa_verify_no_methods' => 'Методы не настроены',
+    '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' => 'Двухфакторный метод настроен, пожалуйста, войдите снова, используя сконфигурированный метод.',
+];
index a0feccf9198a34040b6639289ff844cddcd67503..55b6f737fd722d266f7bf51b3d77c78cfc5b69fd 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Роль',
     'cover_image' => 'Обложка',
     'cover_image_description' => 'Изображение должно быть размером около 440x250px.',
-    
+
     // Actions
     'actions' => 'Действия',
     'view' => 'Просмотр',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Сбросить',
     'remove' => 'Удалить',
     'add' => 'Добавить',
+    'configure' => 'Настройка',
     'fullscreen' => 'На весь экран',
+    'favourite' => 'Избранное',
+    'unfavourite' => 'Убрать из избранного',
+    'next' => 'Следующая',
+    'previous' => 'Предыдущая',
+    'filter_active' => 'Активный фильтр:',
+    'filter_clear' => 'Сбросить фильтр',
 
     // Sort Options
     'sort_options' => 'Параметры сортировки',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Нет действий для просмотра',
     'no_items' => 'Нет доступных элементов',
     'back_to_top' => 'Наверх',
+    'skip_to_main_content' => 'Перейти к основному контенту',
     'toggle_details' => 'Подробности',
     'toggle_thumbnails' => 'Миниатюры',
     'details' => 'Детали',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Вид списком',
     'default' => 'По умолчанию',
     'breadcrumb' => 'Навигация',
+    'status' => 'Состояние',
+    'status_active' => 'Активен',
+    'status_inactive' => 'Неактивен',
+    'never' => 'Никогда',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Развернуть меню заголовка',
index 4e1cca6ee0c0c172b22833bc52d7d5b58c7d326a..1d3e748bc40b6176cb75c115f8bb04aa30e2ffde 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Изображения',
     'my_recent_drafts' => 'Мои последние черновики',
     'my_recently_viewed' => 'Мои недавние просмотры',
+    'my_most_viewed_favourites' => 'Популярное избранное',
+    'my_favourites' => 'Мое избранное',
     'no_pages_viewed' => 'Вы не просматривали ни одной страницы',
     'no_pages_recently_created' => 'Нет недавно созданных страниц',
     'no_pages_recently_updated' => 'Нет недавно обновленных страниц',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Веб файл',
     'export_pdf' => 'PDF файл',
     'export_text' => 'Текстовый файл',
+    'export_md' => 'Файл Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Разрешения',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Доступы к книжной полке',
     'shelves_permissions_updated' => 'Доступы к книжной полке обновлены',
     'shelves_permissions_active' => 'Действующие разрешения книжной полки',
+    'shelves_permissions_cascade_warning' => 'Разрешения на полки не наследуются автоматически содержащимся в них книгам. Это происходит потому, что книга может находиться на нескольких полках. Однако разрешения могут быть установлены для книг полки с помощью опции, приведенной ниже.',
     'shelves_copy_permissions_to_books' => 'Наследовать доступы книгам',
     'shelves_copy_permissions' => 'Копировать доступы',
     'shelves_copy_permissions_explain' => 'Это применит текущие настройки доступов этой книжной полки ко всем книгам, содержащимся внутри. Перед активацией убедитесь, что все изменения в доступах этой книжной полки сохранены.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Главы в конце',
     'books_sort_show_other' => 'Показать другие книги',
     'books_sort_save' => 'Сохранить новый порядок',
+    'books_copy' => 'Копировать книгу',
+    'books_copy_success' => 'Книга успешно скопирована',
 
     // Chapters
     'chapter' => 'Глава',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Переместить главу',
     'chapters_move_named' => 'Переместить главу :chapterName',
     'chapter_move_success' => 'Глава перемещена в :bookName',
+    'chapters_copy' => 'Копировать главу',
+    'chapters_copy_success' => 'Глава успешно скопирована',
     'chapters_permissions' => 'Разрешения главы',
     'chapters_empty' => 'В этой главе нет страниц.',
     'chapters_permissions_active' => 'Действующие разрешения главы',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Новая страница',
     'pages_editing_draft_notification' => 'В настоящее время вы редактируете черновик, который был сохранён :timeDiff.',
     'pages_draft_edited_notification' => 'Эта страница была обновлена до этого момента. Рекомендуется отменить этот черновик.',
+    'pages_draft_page_changed_since_creation' => 'Эта страница была обновлена с момента создания данного черновика. Рекомендуется выбросить этот черновик или следить за тем, чтобы не перезаписать все изменения на странице.',
     'pages_draft_edit_active' => [
         'start_a' => ':count пользователей начали редактирование этой страницы',
         'start_b' => ':userName начал редактирование этой страницы',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Добавьте теги, чтобы лучше классифицировать ваш контент. \\n Вы можете присвоить значение тегу для более глубокой организации.",
     'tags_add' => 'Добавить тег',
     'tags_remove' => 'Удалить этот тег',
+    'tags_usages' => 'Всего использовано тегов',
+    'tags_assigned_pages' => 'Назначено на страницы',
+    'tags_assigned_chapters' => 'Назначено на главы',
+    'tags_assigned_books' => 'Назначено на книги',
+    'tags_assigned_shelves' => 'Назначено на полки',
+    'tags_x_unique_values' => 'Уникальные значения: :count',
+    'tags_all_values' => 'Все значения',
+    'tags_view_tags' => 'Посмотреть теги',
+    'tags_view_existing_tags' => 'Просмотр имеющихся тегов',
+    'tags_list_empty_hint' => 'Теги можно присваивать через боковую панель редактора страниц или при редактировании сведений о книге, главе или полке.',
     'attachments' => 'Вложения',
     'attachments_explain' => 'Загрузите несколько файлов или добавьте ссылку для отображения на своей странице. Они видны на боковой панели страницы.',
     'attachments_explain_instant_save' => 'Изменения здесь сохраняются мгновенно.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Удалить эту версию?',
     'revision_restore_confirm' => 'Вы уверены, что хотите восстановить эту версию? Текущее содержимое страницы будет заменено.',
     'revision_delete_success' => 'Версия удалена',
-    'revision_cannot_delete_latest' => 'Нельзя удалить последнюю версию.'
+    'revision_cannot_delete_latest' => 'Нельзя удалить последнюю версию.',
+
+    // Copy view
+    'copy_consider' => 'При копировании содержимого, пожалуйста, учтите следующее.',
+    'copy_consider_permissions' => 'Пользовательские настройки прав доступа не будут скопированы.',
+    'copy_consider_owner' => 'Вы станете владельцем всего скопированного контента.',
+    'copy_consider_images' => 'Файлы изображений страницы не будут дублироваться и исходные изображения сохранят их отношение к странице, в которую они были загружены изначально.',
+    'copy_consider_attachments' => 'Вложения страницы не будут скопированы.',
+    'copy_consider_access' => 'Изменение положения, владельца или разрешений может привести к тому, что контент будет доступен пользователям, у которых не было доступа ранее.',
 ];
index e8f537ecdaa9e4b22b5b3fd467959cba88d09f66..7ea17aa724eeda26373b697a4b7a5ea76b1bf776 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Не удалось найти email для этого пользователя в данных, предоставленных внешней системой аутентификации',
     'saml_invalid_response_id' => 'Запрос от внешней системы аутентификации не распознается процессом, запущенным этим приложением. Переход назад после входа в систему может вызвать эту проблему.',
     'saml_fail_authed' => 'Вход с помощью :system не удался, система не предоставила успешную авторизацию',
+    'oidc_already_logged_in' => 'Вход в систему уже произведен',
+    'oidc_user_not_registered' => 'Пользователь :name не зарегистрирован и автоматическая регистрация отключена',
+    'oidc_no_email_address' => 'Не удалось найти email этого пользователя в данных, предоставленных внешней системой аутентификации',
+    'oidc_fail_authed' => 'Вход в систему с помощью :system не удался, система не обеспечила успешную авторизацию',
     'social_no_action_defined' => 'Действие не определено',
     'social_login_bad_response' => "При попытке входа с :socialAccount произошла ошибка: \\n:error",
     'social_account_in_use' => 'Этот :socialAccount аккаунт уже используется, попробуйте войти с параметрами :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Страница не найдена',
     'sorry_page_not_found' => 'Извините, страница, которую вы искали, не найдена.',
     'sorry_page_not_found_permission_warning' => 'Если вы ожидали что страница существует, возможно у вас нет прав для её просмотра.',
+    'image_not_found' => 'Изображение не найдено',
+    'image_not_found_subtitle' => 'К сожалению, файл изображения, который вы искали, не найден.',
+    'image_not_found_details' => 'Возможно данное изображение было удалено.',
     'return_home' => 'вернуться на главную страницу',
     'error_occurred' => 'Произошла ошибка',
     'app_down' => ':appName в данный момент не доступно',
index 57e23b3c93a78a0e199c7adef4d2ef104bed5d26..b20db000a147c28100a932ba2e2d79dfd4618c9e 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Обслуживание',
     'maint_image_cleanup' => 'Очистка изображений',
-    'maint_image_cleanup_desc' => "Сканирует содержимое страниц и предыдущих версий и определяет изображения, которые не используются. Убедитесь, что у вас есть резервная копия базы данных и папки изображений перед запуском этой функции.",
+    'maint_image_cleanup_desc' => 'Сканирует содержимое страниц и предыдущих версий и определяет изображения, которые не используются. Убедитесь, что у вас есть резервная копия базы данных и папки изображений перед запуском этой функции.',
     'maint_delete_images_only_in_revisions' => 'Также удалять изображения, которые существуют только в старой версии страницы',
     'maint_image_cleanup_run' => 'Выполнить очистку',
     'maint_image_cleanup_warning' => 'Найдено :count возможно бесполезных изображений. Вы уверены, что хотите удалить эти изображения?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Корзина',
     'recycle_bin_desc' => 'Здесь вы можете восстановить удаленные элементы или навсегда удалить их из системы. Этот список не отфильтрован в отличие от аналогичных списков действий в системе, где применяются фильтры.',
     'recycle_bin_deleted_item' => 'Удаленный элемент',
+    'recycle_bin_deleted_parent' => 'Родительский объект',
     'recycle_bin_deleted_by' => 'Удалён',
     'recycle_bin_deleted_at' => 'Время удаления',
     'recycle_bin_permanently_delete' => 'Удалить навсегда',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Элементы для восстановления',
     'recycle_bin_restore_confirm' => 'Это действие восстановит удаленный элемент, включая дочерние, в исходное место. Если исходное место было удалено и теперь находится в корзине, родительский элемент также необходимо будет восстановить.',
     'recycle_bin_restore_deleted_parent' => 'Родитель этого элемента также был удален. Элементы будут удалены до тех пор, пока этот родитель не будет восстановлен.',
+    'recycle_bin_restore_parent' => 'Восстановить родительский объект',
     'recycle_bin_destroy_notification' => 'Удалено :count элементов из корзины.',
     'recycle_bin_restore_notification' => 'Восстановлено :count элементов из корзины',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Пользователь',
     'audit_table_event' => 'Событие',
     'audit_table_related' => 'Связанный элемент',
+    'audit_table_ip' => 'IP-адрес',
     'audit_table_date' => 'Дата действия',
     'audit_date_from' => 'Диапазон даты от',
     'audit_date_to' => 'Диапазон даты до',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Детали роли',
     'role_name' => 'Название роли',
     'role_desc' => 'Краткое описание роли',
+    'role_mfa_enforced' => 'Требует многофакторной аутентификации',
     'role_external_auth_id' => 'Внешние ID авторизации',
     'role_system' => 'Системные разрешения',
     'role_manage_users' => 'Управление пользователями',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Управление шаблонами страниц',
     'role_access_api' => 'Доступ к системному API',
     'role_manage_settings' => 'Управление настройками приложения',
+    'role_export_content' => 'Экспорт контента',
     'role_asset' => 'Права доступа к материалам',
     'roles_system_warning' => 'Имейте в виду, что доступ к любому из указанных выше трех разрешений может позволить пользователю изменить свои собственные привилегии или привилегии других пользователей системы. Назначать роли с этими правами можно только доверенным пользователям.',
     'role_asset_desc' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Роли пользователя',
     'users_role_desc' => 'Назначьте роли пользователю. Если назначено несколько ролей, разрешения будут суммироваться и пользователь получит все права назначенных ролей.',
     'users_password' => 'Пароль пользователя',
-    'users_password_desc' => 'Установите пароль для входа в приложение. Длина пароля должна быть не менее 6 символов.',
+    'users_password_desc' => 'Установите пароль для входа в приложение. Длина пароля должна быть не менее 8 символов.',
     'users_send_invite_text' => 'Вы можете отправить этому пользователю письмо с приглашением, которое позволит ему установить пароль самостоятельно или задайте пароль сами.',
     'users_send_invite_option' => 'Отправить пользователю письмо с приглашением',
     'users_external_auth_id' => 'Внешний ID аутентификации',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Создать токен',
     'users_api_tokens_expires' => 'Истекает',
     'users_api_tokens_docs' => 'Документация',
+    'users_mfa' => 'Двухфакторная аутентификация',
+    'users_mfa_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
+    'users_mfa_x_methods' => 'методов настроено :count|методов сконфигурировано :count',
+    'users_mfa_configure' => 'Настройка методов',
 
     // API Tokens
     'user_api_token_create' => 'Создать токен',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Вы уверены, что хотите удалить этот API токен?',
     'user_api_token_delete_success' => 'API токен успешно удален',
 
+    // Webhooks
+    'webhooks' => 'Вебхуки',
+    'webhooks_create' => 'Создать вебхук',
+    'webhooks_none_created' => 'Вебхуки еще не созданы.',
+    'webhooks_edit' => 'Редактировать вебхук',
+    'webhooks_save' => 'Сохранить вебхук',
+    'webhooks_details' => 'Детали вебхука',
+    'webhooks_details_desc' => 'Укажите удобное для пользователя название и адрес для отправки данных вебхука с помощью POST.',
+    'webhooks_events' => 'События вебхука',
+    'webhooks_events_desc' => 'Выберите все события, которые должны вызывать этот вебхук.',
+    'webhooks_events_warning' => 'Имейте в виду, что эти события будут срабатывать для всех выбранных событий, даже если применяются пользовательские разрешения. Убедитесь, что использование этого вебхука не будет раскрывать конфиденциальные данные.',
+    'webhooks_events_all' => 'Все системные события',
+    'webhooks_name' => 'Имя вебхука',
+    'webhooks_timeout' => 'Таймаут запроса Webhook (секунды)',
+    'webhooks_endpoint' => 'Конечная точка вебхука',
+    'webhooks_active' => 'Вебхук активен',
+    'webhook_events_table_header' => 'События',
+    'webhooks_delete' => 'Удалить вебхук',
+    'webhooks_delete_warning' => 'Это полностью удалит этот вебхук с названием \':webhookName\' из системы.',
+    'webhooks_delete_confirm' => 'Вы уверены, что хотите удалить этот вебхук?',
+    'webhooks_format_example' => 'Пример вебхука',
+    'webhooks_format_example_desc' => 'Данные вебхука отправляются как POST запрос к настроенной конечной точке в виде JSON в соответствии с форматом ниже. Свойства "related_item" и "url" необязательны и зависят от типа вызванного события.',
+    'webhooks_status' => 'Состояние Webhook',
+    'webhooks_last_called' => 'Последний вызов:',
+    'webhooks_last_errored' => 'Последняя ошибка:',
+    'webhooks_last_error_message' => 'Последнее сообщение об ошибке:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 8c583f7e7820eb72d748ac62ec1d94c6dfb05198..45cc96155dbbff435580c6421a3e3509163cd487 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute может содержать только буквы, цифры и тире.',
     'alpha_num'            => ':attribute должен содержать только буквы и цифры.',
     'array'                => ':attribute должен быть массивом.',
+    'backup_codes'         => 'Указанный код недействителен или уже использован.',
     'before'               => ':attribute дата должна быть до :date.',
     'between'              => [
         'numeric' => ':attribute должен быть между :min и :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute должен быть строкой.',
     'timezone'             => ':attribute должен быть корректным часовым поясом.',
+    'totp'                 => 'Указанный код недействителен или истек.',
     'unique'               => ':attribute уже есть.',
     'url'                  => 'Формат :attribute некорректен.',
     'uploaded'             => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.',
index bbdec3cf7543c2c3d0206096397b41c9e8533336..75850501d7758fde669a39b2f79512611ec45aab 100644 (file)
@@ -8,7 +8,7 @@ return [
     // Pages
     'page_create'                 => 'vytvoril(a) stránku',
     'page_create_notification'    => 'Stránka úspešne vytvorená',
-    'page_update'                 => 'aktualizoval stránku',
+    'page_update'                 => 'aktualizoval(a) stránku',
     'page_update_notification'    => 'Stránka úspešne aktualizovaná',
     'page_delete'                 => 'odstránil(a) stránku',
     'page_delete_notification'    => 'Stránka úspešne odstránená',
@@ -43,6 +43,22 @@ return [
     'bookshelf_delete'                 => 'odstránil(a) knižnicu',
     'bookshelf_delete_notification'    => 'Knižnica úspešne odstránená',
 
+    // Favourites
+    'favourite_add_notification' => '":name" bol pridaný medzi obľúbené',
+    'favourite_remove_notification' => '":name" bol odstránený z obľúbených',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Viacúrovňový spôsob overenia úspešne nastavený',
+    'mfa_remove_method_notification' => 'Viacúrovňový spôsob overenia úspešne odstránený',
+
+    // Webhooks
+    'webhook_create' => 'vytvoril(a) si webhook',
+    'webhook_create_notification' => 'Webhook úspešne vytvorený',
+    'webhook_update' => 'aktualizoval(a) si webhook',
+    'webhook_update_notification' => 'Webhook úspešne aktualizovaný',
+    'webhook_delete' => 'odstránil(a) si webhook',
+    'webhook_delete_notification' => 'Webhook úspešne odstránený',
+
     // Other
     'commented_on'                => 'komentoval(a)',
     'permissions_update'          => 'aktualizované oprávnenia',
index 0d96811a3671aa6bcad8ccf2e80d4ba537c0b464..bed111b05a2fe6283ca720018022033a72ff92df 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-mail',
     'password' => 'Heslo',
     'password_confirm' => 'Potvrdiť heslo',
-    'password_hint' => 'Musí mať viac ako 7 znakov',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Zabudli ste heslo?',
     'remember_me' => 'Zapamätať si ma',
     'ldap_email_hint' => 'Zadajte prosím e-mail, ktorý sa má použiť pre tento účet.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Táto e-mailová doména nemá prístup k tejto aplikácii',
     'register_success' => 'Ďakujeme za registráciu! Teraz ste registrovaný a prihlásený.',
 
-
     // Password Reset
     'reset_password' => 'Resetovanie hesla',
     'reset_password_send_instructions' => 'Nižšie zadajte svoj e-mail, na ktorý Vám zašleme odkaz pre resetovanie hesla.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Tento e-mail ste obdržali, pretože sme dostali požiadavku na resetovanie hesla pre Váš účet.',
     'email_reset_not_requested' => 'Ak ste nepožiadali o resetovanie hesla, nemusíte robiť nič.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Potvrdiť e-mail na :appName',
     'email_confirm_greeting' => 'Ďakujeme, že ste sa pridali k :appName!',
     'email_confirm_text' => 'Prosím, potvrďte Vašu e-mailovú adresu kliknutím na tlačidlo nižšie:',
     'email_confirm_action' => 'Potvrdiť e-mail',
     'email_confirm_send_error' => 'Je požadované overenie e-mailu, ale systém nemohol e-mail odoslať. Kontaktujte administrátora, aby ste sa uistili, že je e-mail nastavený správne.',
-    'email_confirm_success' => 'Váš email bol overený!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Potvrdzujúci e-mail bol poslaný znovu, skontrolujte prosím svoju e-mailovú schránku.',
 
     'email_not_confirmed' => 'E-mailová adresa nebola overená',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Vitajte v :appName!',
     'user_invite_page_text' => 'Ak chcete dokončiť svoj účet a získať prístup, musíte nastaviť heslo, ktoré sa použije na prihlásenie do aplikácie :appName pri budúcich návštevách.',
     'user_invite_page_confirm_button' => 'Potvrdiť heslo',
-    'user_invite_success' => 'Heslo bolo nastavené, teraz máte prístup k :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Nastaviť viacúrovňové prihlasovanie',
+    'mfa_setup_desc' => 'Pre vyššiu úroveň bezpečnosti si nastavte viacúrovňové prihlasovanie.',
+    'mfa_setup_configured' => 'Už nastavené',
+    'mfa_setup_reconfigure' => 'Znovunastavenie',
+    'mfa_setup_remove_confirmation' => 'Ste si istý, že chcete odstrániť tento spôsob viacúrovňového overenia?',
+    'mfa_setup_action' => 'Nastaveine',
+    'mfa_backup_codes_usage_limit_warning' => 'Ostáva vám menej ako 5 záložných kódov. Vygenerujte a uložte si novú súpravu skôr, ako sa vám minú kódy, aby ste sa vyhli vymknutiu z vášho účtu.',
+    'mfa_option_totp_title' => 'Mobilná aplikácia',
+    'mfa_option_totp_desc' => 'Pre používanie viacúrovňového prihlasovania budete potrebovať mobilnú aplikáciu, ktorá podporuje TOPS ako napríklad Google Authenticator, Authy alebo Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Záložné kódy',
+    'mfa_option_backup_codes_desc' => 'Bezpečne uložte jednorázové záložné kódy pre overenie vačej identity.',
+    'mfa_gen_confirm_and_enable' => 'Potvrdiť a zapnúť',
+    'mfa_gen_backup_codes_title' => 'Nastavenie záložných kódov',
+    'mfa_gen_backup_codes_desc' => 'Uložte si tieto kódy na bezpečné miesto. Jeden z kódov budete môcť použiť ako druhý faktor overenia identiy na prihlásenie sa.',
+    'mfa_gen_backup_codes_download' => 'Stiahnuť kódy',
+    'mfa_gen_backup_codes_usage_warning' => 'Každý kód môže byť použitý len jeden krát',
+    'mfa_gen_totp_title' => 'Nastavenie mobilnej aplikácie',
+    'mfa_gen_totp_desc' => 'Pre používanie viacúrovňového prihlasovania budete potrebovať mobilnú aplikáciu, ktorá podporuje TOPS ako napríklad Google Authenticator, Authy alebo Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Naskenujte 1R k\'d pomocou vašej mobilnej aplikácie.',
+    'mfa_gen_totp_verify_setup' => 'Overiť nastavenie',
+    'mfa_gen_totp_verify_setup_desc' => 'Overte, či všetko funguje zadaním kódu vygenerovaného vo vašej autentifikačnej aplikácii do vstupného poľa nižšie:',
+    'mfa_gen_totp_provide_code_here' => 'Sem vložte kód vygenerovaný vašou mobilnou aplikáciou',
+    'mfa_verify_access' => 'Overiť prístup',
+    'mfa_verify_access_desc' => 'Váš používateľský účet vyžaduje, aby ste pred udelením prístupu potvrdili svoju identitu prostredníctvom ďalšej úrovne overenia. Pokračujte overením pomocou jednej z vašich nakonfigurovaných metód.',
+    'mfa_verify_no_methods' => 'Žiadny spôsob nebol nastavený',
+    'mfa_verify_no_methods_desc' => 'Pre váš účet sa nenašli žiadne metódy viacfaktorovej autentifikácie. Pred získaním prístupu budete musieť nastaviť aspoň jednu metódu.',
+    'mfa_verify_use_totp' => 'Overiť pomocou mobilnej aplikácie',
+    'mfa_verify_use_backup_codes' => 'Overiť pomocou záložného kódu',
+    'mfa_verify_backup_code' => 'Záložný kód',
+    'mfa_verify_backup_code_desc' => 'Zadajte jeden zo zostávajúcich záložných kódov:',
+    'mfa_verify_backup_code_enter_here' => 'Zadajte záložný kód',
+    'mfa_verify_totp_desc' => 'Zadajte kód vygenerovaný vašou mobilnou aplikáciou:',
+    'mfa_setup_login_notification' => 'Viacfaktorová metóda je nakonfigurovaná. Teraz sa znova prihláste pomocou nakonfigurovanej metódy.',
+];
index 0d8eb2b6d84648d0a2887c3b49e9d570399f9f27..83fb307e40389b933af69e9105623f2bf7cdaf23 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rola',
     'cover_image' => 'Obal knihy',
     'cover_image_description' => 'Tento obrázok by mal byť približne 300 x 170 pixelov.',
-    
+
     // Actions
     'actions' => 'Akcie',
     'view' => 'Zobraziť',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Resetovať',
     'remove' => 'Odstrániť',
     'add' => 'Pridať',
+    'configure' => 'Konfigurácia',
     'fullscreen' => 'Celá obrazovka',
+    'favourite' => 'Pridať do obľúbených',
+    'unfavourite' => 'Odstrániť z obľúbených',
+    'next' => 'Ďalej',
+    'previous' => 'Späť',
+    'filter_active' => 'Aktívny filter:',
+    'filter_clear' => 'Bez filtrovania',
 
     // Sort Options
     'sort_options' => 'Možnosti triedenia',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Zoradiť vzostupne',
     'sort_descending' => 'Zoradiť zostupne',
     'sort_name' => 'Meno',
-    'sort_default' => 'Default',
+    'sort_default' => 'Východzie',
     'sort_created_at' => 'Dátum vytvorenia',
     'sort_updated_at' => 'Aktualizované dňa',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Žiadna aktivita na zobrazenie',
     'no_items' => 'Žiadne položky nie sú dostupné',
     'back_to_top' => 'Späť nahor',
+    'skip_to_main_content' => 'Preskočiť na hlavný obsah',
     'toggle_details' => 'Prepnúť detaily',
     'toggle_thumbnails' => 'Prepnúť náhľady',
     'details' => 'Podrobnosti',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Zobraziť ako zoznam',
     'default' => 'Predvolené',
     'breadcrumb' => 'Breadcrumb',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Rozbaliť menu v záhlaví',
     'profile_menu' => 'Menu profilu',
     'view_profile' => 'Zobraziť profil',
     'edit_profile' => 'Upraviť profil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informácie',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Tab: Zobraziť vedľajšie informácie',
     'tab_content' => 'Obsah',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Tab: Zobraziť hlavné informácie',
 
     // Email Content
     'email_action_help' => 'Ak máte problém klinkúť na tlačidlo ":actionText", skopírujte a vložte URL uvedenú nižšie do Vášho prehliadača:',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Zásady ochrany osobných údajov',
+    'terms_of_service' => 'Podmienky používania',
 ];
index 058d7f4c915a9bdeb73598f5fd64d450f1c6218f..13e16249f027e715b0a813097e18e4def580cf66 100644 (file)
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => 'Vytvorené :timeLength používateľom :user',
     'meta_updated' => 'Aktualizované :timeLength',
     'meta_updated_name' => 'Aktualizované :timeLength používateľom :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Vlastník :user',
     'entity_select' => 'Entita vybraná',
     'images' => 'Obrázky',
     'my_recent_drafts' => 'Moje nedávne koncepty',
     'my_recently_viewed' => 'Nedávno mnou zobrazené',
+    'my_most_viewed_favourites' => 'Moje najčastejšie zobrazené obľubené',
+    'my_favourites' => 'Moje obľúbené',
     'no_pages_viewed' => 'Nepozreli ste si žiadne stránky',
     'no_pages_recently_created' => 'Žiadne stránky neboli nedávno vytvorené',
     'no_pages_recently_updated' => 'Žiadne stránky neboli nedávno aktualizované',
@@ -34,13 +36,14 @@ return [
     'export_html' => 'Obsahovaný webový súbor',
     'export_pdf' => 'PDF súbor',
     'export_text' => 'Súbor s čistým textom',
+    'export_md' => 'Súbor Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Oprávnenia',
     'permissions_intro' => 'Ak budú tieto oprávnenia povolené, budú mať prioritu pred oprávneniami roly.',
     'permissions_enable' => 'Povoliť vlastné oprávnenia',
     'permissions_save' => 'Uložiť oprávnenia',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Vlastník',
 
     // Search
     'search_results' => 'Výsledky hľadania',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Oprávnenia',
     'search_created_by_me' => 'Vytvorené mnou',
     'search_updated_by_me' => 'Aktualizované mnou',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Patriace mne',
     'search_date_options' => 'Možnosti dátumu',
     'search_updated_before' => 'Aktualizované pred',
     'search_updated_after' => 'Aktualizované po',
@@ -96,22 +99,23 @@ return [
     'shelves_permissions' => 'Oprávnenia knižnice',
     'shelves_permissions_updated' => 'Oprávnenia knižnice aktualizované',
     'shelves_permissions_active' => 'Oprávnenia knižnice aktívne',
-    'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
-    'shelves_copy_permissions' => 'Copy Permissions',
-    'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
-    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
+    'shelves_permissions_cascade_warning' => 'Povolenia na poličkách sa automaticky nepriraďujú k obsiahnutým knihám. Je to preto, že kniha môže existovať na viacerých poličkách. Povolenia však možno skopírovať do kníh pomocou možnosti uvedenej nižšie.',
+    'shelves_copy_permissions_to_books' => 'Kopírovať oprávnenia pre knihy',
+    'shelves_copy_permissions' => 'Kopírovať oprávnenia',
+    'shelves_copy_permissions_explain' => 'Týmto sa použijú aktuálne nastavenia povolení tejto police na všetky knihy, ktoré obsahuje. Pred aktiváciou sa uistite, že všetky zmeny povolení tejto police boli uložené.',
+    'shelves_copy_permission_success' => 'Oprávnenia knižnice boli skopírované {0}:count kníh|{1}:count kniha|[2,3,4]:count knihy|[5,*]:count kníh',
 
     // Books
     'book' => 'Kniha',
     'books' => 'Knihy',
-    'x_books' => ':count Book|:count Books',
+    'x_books' => '{0}:count kníh|{1}:count kniha|[2,3,4]:count knihy|[5,*]:count kníh',
     'books_empty' => 'Žiadne knihy neboli vytvorené',
     'books_popular' => 'Populárne knihy',
     'books_recent' => 'Nedávne knihy',
-    'books_new' => 'New Books',
-    'books_new_action' => 'New Book',
+    'books_new' => 'Nové knihy',
+    'books_new_action' => 'Nová kniha',
     'books_popular_empty' => 'Najpopulárnejšie knihy sa objavia tu.',
-    'books_new_empty' => 'The most recently created books will appear here.',
+    'books_new_empty' => 'Najnovšie knihy sa zobrazia tu.',
     'books_create' => 'Vytvoriť novú knihu',
     'books_delete' => 'Zmazať knihu',
     'books_delete_named' => 'Zmazať knihu :bookName',
@@ -132,24 +136,26 @@ return [
     'books_navigation' => 'Navigácia knihy',
     'books_sort' => 'Zoradiť obsah knihy',
     'books_sort_named' => 'Zoradiť knihu :bookName',
-    'books_sort_name' => 'Sort by Name',
-    'books_sort_created' => 'Sort by Created Date',
-    'books_sort_updated' => 'Sort by Updated Date',
-    'books_sort_chapters_first' => 'Chapters First',
-    'books_sort_chapters_last' => 'Chapters Last',
+    'books_sort_name' => 'Zoradiť podľa mena',
+    'books_sort_created' => 'Zoradiť podľa dátumu vytvorenia',
+    'books_sort_updated' => 'Zoradiť podľa dátumu aktualizácie',
+    'books_sort_chapters_first' => 'Kapitoly ako prvé',
+    'books_sort_chapters_last' => 'Kapitoly ako posledné',
     'books_sort_show_other' => 'Zobraziť ostatné knihy',
     'books_sort_save' => 'Uložiť nové zoradenie',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Kapitola',
     'chapters' => 'Kapitoly',
-    'x_chapters' => ':count Chapter|:count Chapters',
+    'x_chapters' => '{0}:count Kapitol|{1}:count Kapitola|[2,3,4]:count Kapitoly|[5,*]:count Kapitol',
     'chapters_popular' => 'Populárne kapitoly',
     'chapters_new' => 'Nová kapitola',
     'chapters_create' => 'Vytvoriť novú kapitolu',
     'chapters_delete' => 'Zmazať kapitolu',
     'chapters_delete_named' => 'Zmazať kapitolu :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
+    'chapters_delete_explain' => 'Týmto sa odstráni kapitola s názvom \':chapterName\'. Spolu s ňou sa odstránia všetky stránky v tejto kapitole.',
     'chapters_delete_confirm' => 'Ste si istý, že chcete zmazať túto kapitolu?',
     'chapters_edit' => 'Upraviť kapitolu',
     'chapters_edit_named' => 'Upraviť kapitolu :chapterName',
@@ -157,11 +163,13 @@ return [
     'chapters_move' => 'Presunúť kapitolu',
     'chapters_move_named' => 'Presunúť kapitolu :chapterName',
     'chapter_move_success' => 'Kapitola presunutá do :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Oprávnenia kapitoly',
     'chapters_empty' => 'V tejto kapitole nie sú teraz žiadne stránky.',
     'chapters_permissions_active' => 'Oprávnenia kapitoly aktívne',
     'chapters_permissions_success' => 'Oprávnenia kapitoly aktualizované',
-    'chapters_search_this' => 'Search this chapter',
+    'chapters_search_this' => 'Hladať v kapitole',
 
     // Pages
     'page' => 'Stránka',
@@ -180,7 +188,7 @@ return [
     'pages_delete_confirm' => 'Ste si istý, že chcete zmazať túto stránku?',
     'pages_delete_draft_confirm' => 'Ste si istý, že chcete zmazať tento koncept stránky?',
     'pages_editing_named' => 'Upraviť stránku :pageName',
-    'pages_edit_draft_options' => 'Draft Options',
+    'pages_edit_draft_options' => 'Možnosti konceptu',
     'pages_edit_save_draft' => 'Uložiť koncept',
     'pages_edit_draft' => 'Upraviť koncept stránky',
     'pages_editing_draft' => 'Upravuje sa koncept',
@@ -198,25 +206,25 @@ return [
     'pages_md_preview' => 'Náhľad',
     'pages_md_insert_image' => 'Vložiť obrázok',
     'pages_md_insert_link' => 'Vložiť odkaz na entitu',
-    'pages_md_insert_drawing' => 'Insert Drawing',
+    'pages_md_insert_drawing' => 'Vložiť kresbu',
     'pages_not_in_chapter' => 'Stránka nie je v kapitole',
     'pages_move' => 'Presunúť stránku',
     'pages_move_success' => 'Stránka presunutá do ":parentName"',
-    'pages_copy' => 'Copy Page',
-    'pages_copy_desination' => 'Copy Destination',
-    'pages_copy_success' => 'Page successfully copied',
+    'pages_copy' => 'Kpoírovať stránku',
+    'pages_copy_desination' => 'Ciel kopírovania',
+    'pages_copy_success' => 'Stránka bola skopírovaná',
     'pages_permissions' => 'Oprávnenia stránky',
     'pages_permissions_success' => 'Oprávnenia stránky aktualizované',
-    'pages_revision' => 'Revision',
+    'pages_revision' => 'Revízia',
     'pages_revisions' => 'Revízie stránky',
     'pages_revisions_named' => 'Revízie stránky :pageName',
     'pages_revision_named' => 'Revízia stránky :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Obnovené z #:id; :summary',
     'pages_revisions_created_by' => 'Vytvoril',
     'pages_revisions_date' => 'Dátum revízie',
-    'pages_revisions_number' => '#',
-    'pages_revisions_numbered' => 'Revision #:id',
-    'pages_revisions_numbered_changes' => 'Revision #:id Changes',
+    'pages_revisions_number' => 'č.',
+    'pages_revisions_numbered' => 'Revízia č. :id',
+    'pages_revisions_numbered_changes' => 'Zmeny revízie č. ',
     'pages_revisions_changelog' => 'Záznam zmien',
     'pages_revisions_changes' => 'Zmeny',
     'pages_revisions_current' => 'Aktuálna verzia',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nová stránka',
     'pages_editing_draft_notification' => 'Práve upravujete koncept, ktorý bol naposledy uložený :timeDiff.',
     'pages_draft_edited_notification' => 'Táto stránka bola odvtedy upravená. Odporúča sa odstrániť tento koncept.',
+    'pages_draft_page_changed_since_creation' => 'Táto stránka bola aktualizovaná od vytvorenia tohto konceptu. Odporúča sa, aby ste tento koncept zahodili alebo aby ste neprepísali žiadne zmeny stránky.',
     'pages_draft_edit_active' => [
         'start_a' => ':count používateľov začalo upravovať túto stránku',
         'start_b' => ':userName začal upravovať túto stránku',
@@ -238,21 +247,31 @@ return [
         'message' => ':start :time. Dávajte pozor aby ste si navzájom neprepísali zmeny!',
     ],
     'pages_draft_discarded' => 'Koncept ostránený, aktuálny obsah stránky bol nahraný do editora',
-    'pages_specific' => 'Specific Page',
-    'pages_is_template' => 'Page Template',
+    'pages_specific' => 'Konkrétna stránka',
+    'pages_is_template' => 'Šablóna stránky',
 
     // Editor Sidebar
     'page_tags' => 'Štítky stránok',
-    'chapter_tags' => 'Chapter Tags',
-    'book_tags' => 'Book Tags',
-    'shelf_tags' => 'Shelf Tags',
+    'chapter_tags' => 'Štítky kapitol',
+    'book_tags' => 'Štítky kníh',
+    'shelf_tags' => 'Štítky knižníc',
     'tag' => 'Štítok',
     'tags' =>  'Štítky',
-    'tag_name' =>  'Tag Name',
+    'tag_name' =>  'Názov štítku',
     'tag_value' => 'Hodnota štítku (Voliteľné)',
     'tags_explain' => "Pridajte pár štítkov pre uľahčenie kategorizácie Vášho obsahu. \n Štítku môžete priradiť hodnotu pre ešte lepšiu organizáciu.",
     'tags_add' => 'Pridať ďalší štítok',
-    'tags_remove' => 'Remove this tag',
+    'tags_remove' => 'Odstrániť tento štítok',
+    'tags_usages' => 'Celkové využitie značiek',
+    'tags_assigned_pages' => 'Priradené k stránkam',
+    'tags_assigned_chapters' => 'Priradené ku kapitolám',
+    'tags_assigned_books' => 'Priradené ku knihám',
+    'tags_assigned_shelves' => 'Priradené k poličkám',
+    'tags_x_unique_values' => ':count jedinečné hodnoty',
+    'tags_all_values' => 'Všetky hodnoty',
+    'tags_view_tags' => 'Zobraziť značky',
+    'tags_view_existing_tags' => 'Zobraziť existujúce značky',
+    'tags_list_empty_hint' => 'Značky je možné priradiť prostredníctvom postranného panela editora stránok alebo pri úprave podrobností o knihe, kapitole alebo poličke.',
     'attachments' => 'Prílohy',
     'attachments_explain' => 'Nahrajte nejaké súbory alebo priložte zopár odkazov pre zobrazenie na Vašej stránke. Budú viditeľné v bočnom paneli.',
     'attachments_explain_instant_save' => 'Zmeny budú okamžite uložené.',
@@ -260,7 +279,7 @@ return [
     'attachments_upload' => 'Nahrať súbor',
     'attachments_link' => 'Priložiť odkaz',
     'attachments_set_link' => 'Nastaviť odkaz',
-    'attachments_delete' => 'Are you sure you want to delete this attachment?',
+    'attachments_delete' => 'Naozaj chcete odstrániť túto prílohu?',
     'attachments_dropzone' => 'Presuňte súbory alebo klinknite sem pre priloženie súboru',
     'attachments_no_files' => 'Žiadne súbory neboli nahrané',
     'attachments_explain_link' => 'Ak nechcete priložiť súbor, môžete priložiť odkaz. Môže to byť odkaz na inú stránku alebo odkaz na súbor v cloude.',
@@ -269,7 +288,7 @@ return [
     'attachments_link_url' => 'Odkaz na súbor',
     'attachments_link_url_hint' => 'Url stránky alebo súboru',
     'attach' => 'Priložiť',
-    'attachments_insert_link' => 'Add Attachment Link to Page',
+    'attachments_insert_link' => 'Pridať odkaz na prílohu',
     'attachments_edit_file' => 'Upraviť súbor',
     'attachments_edit_file_name' => 'Názov súboru',
     'attachments_edit_drop_upload' => 'Presuňte súbory sem alebo klinknite pre nahranie a prepis',
@@ -279,12 +298,12 @@ return [
     'attachments_file_uploaded' => 'Súbor úspešne nahraný',
     'attachments_file_updated' => 'Súbor úspešne aktualizovaný',
     'attachments_link_attached' => 'Odkaz úspešne pripojený k stránke',
-    'templates' => 'Templates',
-    'templates_set_as_template' => 'Page is a template',
-    'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
-    'templates_replace_content' => 'Replace page content',
-    'templates_append_content' => 'Append to page content',
-    'templates_prepend_content' => 'Prepend to page content',
+    'templates' => 'Šablóny',
+    'templates_set_as_template' => 'Táto stránka je šablóna',
+    'templates_explain_set_as_template' => 'Túto stránku môžete nastaviť ako šablónu, aby sa jej obsah použil pri vytváraní ďalších stránok. Ostatní používatelia budú môcť použiť túto šablónu, ak majú povolenia na zobrazenie tejto stránky.',
+    'templates_replace_content' => 'Nahradiť obsah',
+    'templates_append_content' => 'Pripojiť k obsahu stránky',
+    'templates_prepend_content' => 'Pridať pred obsah stránky',
 
     // Profile View
     'profile_user_for_x' => 'Používateľ už :time',
@@ -292,23 +311,23 @@ return [
     'profile_not_created_pages' => ':userName nevytvoril žiadne stránky',
     'profile_not_created_chapters' => ':userName nevytvoril žiadne kapitoly',
     'profile_not_created_books' => ':userName nevytvoril žiadne knihy',
-    'profile_not_created_shelves' => ':userName has not created any shelves',
+    'profile_not_created_shelves' => ':userName nevytvoril(a) žiadne kapitoly',
 
     // Comments
     'comment' => 'Komentár',
     'comments' => 'Komentáre',
-    'comment_add' => 'Add Comment',
+    'comment_add' => 'Pridať komentár',
     'comment_placeholder' => 'Tu zadajte svoje pripomienky',
-    'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
+    'comment_count' => '{0} Bez komentárov|{1} 1 komentár|[2,3,4] :count komentáre|[5,*] :count komentárov',
     'comment_save' => 'Uložiť komentár',
-    'comment_saving' => 'Saving comment...',
-    'comment_deleting' => 'Deleting comment...',
-    'comment_new' => 'New Comment',
-    'comment_created' => 'commented :createDiff',
-    'comment_updated' => 'Updated :updateDiff by :username',
-    'comment_deleted_success' => 'Comment deleted',
-    'comment_created_success' => 'Comment added',
-    'comment_updated_success' => 'Comment updated',
+    'comment_saving' => 'Ukladanie komentára...',
+    'comment_deleting' => 'Mazanie komentára...',
+    'comment_new' => 'Nový komentár',
+    'comment_created' => 'komentované :createDiff',
+    'comment_updated' => 'Aktualizované :updateDiff užívateľom :username',
+    'comment_deleted_success' => 'Komentár odstránený',
+    'comment_created_success' => 'Komentár pridaný',
+    'comment_updated_success' => 'Komentár aktualizovaný',
     'comment_delete_confirm' => 'Ste si istý, že chcete odstrániť tento komentár?',
     'comment_in_reply_to' => 'Odpovedať na :commentId',
 
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Naozaj chcete túto revíziu odstrániť?',
     'revision_restore_confirm' => 'Naozaj chcete obnoviť túto revíziu? Aktuálny obsah stránky sa nahradí.',
     'revision_delete_success' => 'Revízia bola vymazaná',
-    'revision_cannot_delete_latest' => 'Nie je možné vymazať poslednú revíziu.'
+    'revision_cannot_delete_latest' => 'Nie je možné vymazať poslednú revíziu.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index c045d473d05e99e86971523018513dc50de667d8..48aea409e192c24f8d88ac5387d17ab6da575ba5 100644 (file)
@@ -13,18 +13,22 @@ return [
     'email_already_confirmed' => 'Email bol už overený, skúste sa prihlásiť.',
     'email_confirmation_invalid' => 'Tento potvrdzujúci token nie je platný alebo už bol použitý, skúste sa prosím registrovať znova.',
     'email_confirmation_expired' => 'Potvrdzujúci token expiroval, bol odoslaný nový potvrdzujúci email.',
-    'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed',
-    'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind',
-    'ldap_fail_authed' => 'LDAP access failed using given dn & password details',
-    'ldap_extension_not_installed' => 'LDAP PHP extension not installed',
-    'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed',
-    'saml_already_logged_in' => 'Already logged in',
-    'saml_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
-    'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
-    'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
-    'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
+    'email_confirmation_awaiting' => 'Potvrďte emailovú adresu pre užívateľský účet',
+    'ldap_fail_anonymous' => 'Prístup LDAP zlyhal',
+    'ldap_fail_authed' => 'Prístup LDAP zlyhal pomocou zadaných podrobností dn a hesla',
+    'ldap_extension_not_installed' => 'Rozšírenie LDAP PHP nie je nainštalované',
+    'ldap_cannot_connect' => 'Nedá sa pripojiť k serveru ldap, počiatočné pripojenie zlyhalo',
+    'saml_already_logged_in' => 'Používateľ sa už prihlásil',
+    'saml_user_not_registered' => 'Používateľ :name nie je zaregistrovaný a automatická registrácia je zakázaná',
+    'saml_no_email_address' => 'V údajoch poskytnutých externým overovacím systémom sa nepodarilo nájsť e-mailovú adresu tohto používateľa',
+    'saml_invalid_response_id' => 'Požiadavka z externého autentifikačného systému nie je rozpoznaná procesom spusteným touto aplikáciou. Tento problém môže spôsobiť navigácia späť po prihlásení.',
+    'saml_fail_authed' => 'Prihlásenie pomocou :system zlyhalo, systém neposkytol úspešnú autorizáciu',
+    'oidc_already_logged_in' => 'Používateľ sa už prihlásil',
+    'oidc_user_not_registered' => 'Používateľ :name nie je zaregistrovaný a automatická registrácia je zakázaná',
+    'oidc_no_email_address' => 'V údajoch poskytnutých externým overovacím systémom sa nepodarilo nájsť e-mailovú adresu tohto používateľa',
+    'oidc_fail_authed' => 'Prihlásenie pomocou :system zlyhalo, systém neposkytol úspešnú autorizáciu',
     'social_no_action_defined' => 'Nebola definovaná žiadna akcia',
-    'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
+    'social_login_bad_response' => "Pri prihlásení do účtu :socialAccount došlo k chybe:\n:error",
     'social_account_in_use' => 'Tento :socialAccount účet sa už používa, skúste sa prihlásiť pomocou možnosti :socialAccount.',
     'social_account_email_in_use' => 'Email :email sa už používa. Ak už máte účet, môžete pripojiť svoj :socialAccount účet v nastaveniach profilu.',
     'social_account_existing' => 'Tento :socialAccount účet je už spojený s Vaším profilom.',
@@ -33,28 +37,28 @@ return [
     'social_account_register_instructions' => 'Ak zatiaľ nemáte účet, môžete sa registrovať pomocou možnosti :socialAccount.',
     'social_driver_not_found' => 'Ovládač socialnych sietí nebol nájdený',
     'social_driver_not_configured' => 'Nastavenia Vášho :socialAccount účtu nie sú správne.',
-    'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
+    'invite_token_expired' => 'Platnosť tohto odkazu na pozvánku vypršala. Namiesto toho sa môžete pokúsiť obnoviť heslo účtu.',
 
     // System
     'path_not_writable' => 'Do cesty :filePath sa nedá nahrávať. Uistite sa, že je zapisovateľná serverom.',
     'cannot_get_image_from_url' => 'Nedá sa získať obrázok z :url',
     'cannot_create_thumbs' => 'Server nedokáže vytvoriť náhľady. Skontrolujte prosím, či máte nainštalované GD rozšírenie PHP.',
     'server_upload_limit' => 'Server nedovoľuje nahrávanie súborov s takouto veľkosťou. Skúste prosím menší súbor.',
-    'uploaded'  => 'The server does not allow uploads of this size. Please try a smaller file size.',
+    'uploaded'  => 'Server nedovoľuje nahrávanie súborov s takouto veľkosťou. Skúste prosím menší súbor.',
     'image_upload_error' => 'Pri nahrávaní obrázka nastala chyba',
-    'image_upload_type_error' => 'The image type being uploaded is invalid',
+    'image_upload_type_error' => 'Typ nahrávaného obrázka je neplatný',
     'file_upload_timeout' => 'Nahrávanie súboru vypršalo.',
 
     // Attachments
-    'attachment_not_found' => 'Attachment not found',
+    'attachment_not_found' => 'Príloha nenájdená',
 
     // Pages
     'page_draft_autosave_fail' => 'Koncept nemohol byť uložený. Uistite sa, že máte pripojenie k internetu pre uložením tejto stránky',
-    'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
+    'page_custom_home_deletion' => 'Stránku nie je možné odstrániť, kým je nastavená ako domovská stránka',
 
     // Entities
     'entity_not_found' => 'Entita nenájdená',
-    'bookshelf_not_found' => 'Bookshelf not found',
+    'bookshelf_not_found' => 'Polička nenájdená',
     'book_not_found' => 'Kniha nenájdená',
     'page_not_found' => 'Stránka nenájdená',
     'chapter_not_found' => 'Kapitola nenájdená',
@@ -70,7 +74,7 @@ return [
     'role_cannot_be_edited' => 'Táto rola nemôže byť upravovaná',
     'role_system_cannot_be_deleted' => 'Táto rola je systémová rola a nemôže byť zmazaná',
     'role_registration_default_cannot_delete' => 'Táto rola nemôže byť zmazaná, pretože je nastavená ako prednastavená rola pri registrácii',
-    'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.',
+    'role_cannot_remove_only_admin' => 'Tento používateľ je jediným používateľom priradeným k role správcu. Priraďte rolu správcu inému používateľovi skôr, ako sa ju pokúsite odstrániť tu.',
 
     // Comments
     'comment_list' => 'Pri načítaní komentárov sa vyskytla chyba',
@@ -82,21 +86,24 @@ return [
     // Error pages
     '404_page_not_found' => 'Stránka nenájdená',
     'sorry_page_not_found' => 'Prepáčte, stránka ktorú hľadáte nebola nájdená.',
-    'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
+    'sorry_page_not_found_permission_warning' => 'Ak ste očakávali existenciu tejto stránky, možno nemáte povolenie na jej zobrazenie.',
+    'image_not_found' => 'Obrázok nebol nájdený',
+    'image_not_found_subtitle' => 'Ľutujeme, obrázok, ktorý ste hľadali, sa nepodarilo nájsť.',
+    'image_not_found_details' => 'Ak ste očakávali, že tento obrázok existuje, mohol byť odstránený.',
     'return_home' => 'Vrátiť sa domov',
     'error_occurred' => 'Nastala chyba',
     'app_down' => ':appName je momentálne nedostupná',
     'back_soon' => 'Čoskoro bude opäť dostupná.',
 
     // API errors
-    'api_no_authorization_found' => 'No authorization token found on the request',
-    'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect',
-    'api_user_token_not_found' => 'No matching API token was found for the provided authorization token',
-    'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect',
-    'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls',
-    'api_user_token_expired' => 'The authorization token used has expired',
+    'api_no_authorization_found' => 'V žiadosti sa nenašiel žiadny autorizačný token',
+    'api_bad_authorization_format' => 'V žiadosti sa našiel autorizačný token, ale formát sa zdal nesprávny',
+    'api_user_token_not_found' => 'Pre poskytnutý autorizačný token sa nenašiel žiadny zodpovedajúci token rozhrania API',
+    'api_incorrect_token_secret' => 'Secret poskytnutý pre daný token API je nesprávny',
+    'api_user_no_api_permission' => 'Vlastník použitého tokenu API nemá povolenie na uskutočňovanie volaní rozhrania API',
+    'api_user_token_expired' => 'Platnosť použitého autorizačného tokenu vypršala',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'Chyba pri odosielaní testovacieho e-mailu:',
 
 ];
index 60a1a5add6c06164ed83c89f1c900ffbbc33e7d1..e94215d9537cbb1c35aa09eb1f961bbae76bf576 100644 (file)
@@ -12,114 +12,117 @@ return [
     'settings_save_success' => 'Nastavenia uložené',
 
     // App Settings
-    'app_customization' => 'Customization',
-    'app_features_security' => 'Features & Security',
+    'app_customization' => 'Prispôsobenia',
+    'app_features_security' => 'Funkcie a bezpečnosť',
     'app_name' => 'Názov aplikácia',
     'app_name_desc' => 'Tento názov sa zobrazuje v hlavičke a v emailoch.',
     'app_name_header' => 'Zobraziť názov aplikácie v hlavičke?',
-    'app_public_access' => 'Public Access',
-    'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.',
-    'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.',
-    'app_public_access_toggle' => 'Allow public access',
+    'app_public_access' => 'Verejný prístup',
+    'app_public_access_desc' => 'Povolenie tejto možnosti umožní návštevníkom, ktorí nie sú prihlásení, prístup k obsahu vo vašej inštancii BookStack.',
+    'app_public_access_desc_guest' => 'Prístup pre verejných návštevníkov je možné ovládať prostredníctvom používateľa "Hosť".',
+    'app_public_access_toggle' => 'Povoliť verejný prístup',
     'app_public_viewing' => 'Povoliť verejné zobrazenie?',
     'app_secure_images' => 'Povoliť nahrávanie súborov so zvýšeným zabezpečením?',
-    'app_secure_images_toggle' => 'Enable higher security image uploads',
+    'app_secure_images_toggle' => 'Povoliť nahrávanie obrázkov s vyšším zabezpečením',
     'app_secure_images_desc' => 'Kvôli výkonu sú všetky obrázky verejné. Táto možnosť pridá pred URL obrázka náhodný, ťažko uhádnuteľný reťazec. Aby ste zabránili jednoduchému prístupu, uistite sa, že indexy priečinkov nie sú povolené.',
     'app_editor' => 'Editor stránky',
     'app_editor_desc' => 'Vyberte editor, ktorý bude používaný všetkými používateľmi na editáciu stránok.',
     'app_custom_html' => 'Vlastný HTML obsah hlavičky',
     'app_custom_html_desc' => 'Všetok text pridaný sem bude vložený naspodok <head> sekcie na každej stránke. Môže sa to zísť pri zmene štýlu alebo pre pridanie analytického kódu.',
-    'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
+    'app_custom_html_disabled_notice' => 'Vlastný obsah hlavičky HTML je na tejto stránke s nastaveniami zakázaný, aby sa zabezpečilo, že sa dajú vrátiť zmeny, ktoré nastali.',
     'app_logo' => 'Logo aplikácie',
     'app_logo_desc' => 'Tento obrázok by mal mať 43px na výšku. <br>Veľké obrázky budú preškálované na menší rozmer.',
     'app_primary_color' => 'Primárna farba pre aplikáciu',
     'app_primary_color_desc' => 'Toto by mala byť hodnota v hex tvare. <br>Nechajte prázdne ak chcete použiť prednastavenú farbu.',
-    'app_homepage' => 'Application Homepage',
-    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_select' => 'Select a page',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
-    'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_homepage' => 'Domovská stránka aplikácie',
+    'app_homepage_desc' => 'Vyberte zobrazenie, ktoré sa má zobraziť na domovskej stránke namiesto predvoleného zobrazenia. Povolenia stránky sa pre vybraté stránky ignorujú.',
+    'app_homepage_select' => 'Vybrať stránku',
+    'app_footer_links' => 'Odkazy v pätičke',
+    'app_footer_links_desc' => 'Pridajte odkazy, ktoré sa majú zobraziť v päte lokality. Tieto sa zobrazia v spodnej časti väčšiny stránok, vrátane tých, ktoré nevyžadujú prihlásenie. Ak chcete použiť preklady definované systémom, môžete použiť označenie "trans::<key>". Napríklad: Použitie „trans::common.privacy_policy“ poskytne preložený text „Zásady ochrany osobných údajov“ a „trans::common.terms_of_service“ poskytne preložený text „Zmluvné podmienky“.',
+    'app_footer_links_label' => 'Označenie odkazu',
+    'app_footer_links_url' => 'URL odkaz',
+    'app_footer_links_add' => 'Pridať odkaz na pätu',
     'app_disable_comments' => 'Zakázať komentáre',
-    'app_disable_comments_toggle' => 'Disable comments',
+    'app_disable_comments_toggle' => 'Vypnúť komentáre',
     'app_disable_comments_desc' => 'Zakázať komentáre na všetkých stránkach aplikácie. Existujúce komentáre sa nezobrazujú.',
 
     // Color settings
-    'content_colors' => 'Content Colors',
-    'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
-    'bookshelf_color' => 'Shelf Color',
-    'book_color' => 'Book Color',
-    'chapter_color' => 'Chapter Color',
-    'page_color' => 'Page Color',
-    'page_draft_color' => 'Page Draft Color',
+    'content_colors' => 'Farby obsahu',
+    'content_colors_desc' => 'Nastaví farby pre všetky prvky v hierarchii organizácie stránky. Kvôli čitateľnosti sa odporúča vybrať farby s podobným jasom ako predvolené farby.',
+    'bookshelf_color' => 'Farba police',
+    'book_color' => 'Farba knihy',
+    'chapter_color' => 'Farba kapitoly',
+    'page_color' => 'Farba stránky',
+    'page_draft_color' => 'Farba konceptu stránky',
 
     // Registration Settings
     'reg_settings' => 'Nastavenia registrácie',
-    'reg_enable' => 'Enable Registration',
-    'reg_enable_toggle' => 'Enable registration',
-    'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.',
+    'reg_enable' => 'Povolenie registrácie',
+    'reg_enable_toggle' => 'Povoliť registrácie',
+    'reg_enable_desc' => 'Keď je registrácia povolená, používateľ sa bude môcť prihlásiť ako používateľ aplikácie. Po registrácii dostane predvolenú používateľskú rolu.',
     'reg_default_role' => 'Prednastavená používateľská rola po registrácii',
-    'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.',
-    'reg_email_confirmation' => 'Email Confirmation',
-    'reg_email_confirmation_toggle' => 'Require email confirmation',
+    'reg_enable_external_warning' => 'Ak je aktívna externá autentifikácia LDAP alebo SAML, možnosť vyššie sa ignoruje. Používateľské účty pre neexistujúcich členov sa vytvoria automaticky, ak je overenie proti používanému externému systému úspešné.',
+    'reg_email_confirmation' => 'Potvrdenie e-mailom',
+    'reg_email_confirmation_toggle' => 'Vyžadovať potvrdenie e-mailom',
     'reg_confirm_email_desc' => 'Ak je použité obmedzenie domény, potom bude vyžadované overenie emailu a hodnota nižšie bude ignorovaná.',
     'reg_confirm_restrict_domain' => 'Obmedziť registráciu na doménu',
     'reg_confirm_restrict_domain_desc' => 'Zadajte zoznam domén, pre ktoré chcete povoliť registráciu oddelených čiarkou. Používatelia dostanú email kvôli overeniu adresy predtým ako im bude dovolené používať aplikáciu. <br> Používatelia si budú môcť po úspešnej registrácii zmeniť svoju emailovú adresu.',
     'reg_confirm_restrict_domain_placeholder' => 'Nie sú nastavené žiadne obmedzenia',
 
     // Maintenance settings
-    'maint' => 'Maintenance',
-    'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
-    'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
-    'maint_image_cleanup_run' => 'Run Cleanup',
-    'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
-    'maint_image_cleanup_success' => ':count potentially unused images found and deleted!',
-    'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!',
-    'maint_send_test_email' => 'Send a Test Email',
-    'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.',
-    'maint_send_test_email_run' => 'Send test email',
-    'maint_send_test_email_success' => 'Email sent to :address',
-    'maint_send_test_email_mail_subject' => 'Test Email',
-    'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
-    'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
-    'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
-    'maint_recycle_bin_open' => 'Open Recycle Bin',
+    'maint' => 'Údržba',
+    'maint_image_cleanup' => 'Prečistenie obrázkov',
+    'maint_image_cleanup_desc' => 'Skenovať obsah stránky a revízie, aby sa skontrolovalo, ktoré obrázky a návrhy sa momentálne používajú a ktoré obrázky sú nadbytočné. Pred spustením sa uistite, že ste vytvorili úplnú zálohu obrazu a databázy.',
+    'maint_delete_images_only_in_revisions' => 'Odstráňte aj obrázky, ktoré existujú iba v starých revíziách stránok',
+    'maint_image_cleanup_run' => 'Spustiť prečistenie',
+    'maint_image_cleanup_warning' => ':count nájdených potenciálne nepoužitých obrázkov. Naozaj chcete odstrániť tieto obrázky?',
+    'maint_image_cleanup_success' => ':count potenciálne nepoužité obrázky boli nájdené a odstránené!',
+    'maint_image_cleanup_nothing_found' => 'Žiadne nepoužit obrázky neboli nájdené. Nič sa nezmazalo!',
+    'maint_send_test_email' => 'Odoslať testovací email',
+    'maint_send_test_email_desc' => 'Týmto sa odošle testovací e-mail na vašu e-mailovú adresu uvedenú vo vašom profile.',
+    'maint_send_test_email_run' => 'Odoslať testovací email',
+    'maint_send_test_email_success' => 'Email odoslaný na :address',
+    'maint_send_test_email_mail_subject' => 'Testovací email',
+    'maint_send_test_email_mail_greeting' => 'Zdá sa, že doručovanie e-mailov funguje!',
+    'maint_send_test_email_mail_text' => 'Gratulujeme! Keď ste dostali toto e-mailové upozornenie, zdá sa, že vaše nastavenia e-mailu sú nakonfigurované správne.',
+    'maint_recycle_bin_desc' => 'Vymazané police, knihy, kapitoly a strany sa odošlú do koša, aby sa dali obnoviť alebo natrvalo odstrániť. Staršie položky z koša môžu byť po chvíli automaticky odstránené v závislosti od konfigurácie systému.',
+    'maint_recycle_bin_open' => 'Otvoriť kôš',
 
     // Recycle Bin
-    'recycle_bin' => 'Recycle Bin',
-    'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'recycle_bin_deleted_item' => 'Deleted Item',
-    'recycle_bin_deleted_by' => 'Deleted By',
-    'recycle_bin_deleted_at' => 'Deletion Time',
-    'recycle_bin_permanently_delete' => 'Permanently Delete',
-    'recycle_bin_restore' => 'Restore',
-    'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
-    'recycle_bin_empty' => 'Empty Recycle Bin',
-    'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
-    'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
-    'recycle_bin_destroy_list' => 'Items to be Destroyed',
-    'recycle_bin_restore_list' => 'Items to be Restored',
-    'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
-    'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
-    'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
-    'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
+    'recycle_bin' => 'Kôš',
+    'recycle_bin_desc' => 'Tu môžete obnoviť položky, ktoré boli odstránené, alebo zvoliť ich trvalé odstránenie zo systému. Tento zoznam je nefiltrovaný na rozdiel od podobných zoznamov aktivít v systéme, kde sa používajú filtre povolení.',
+    'recycle_bin_deleted_item' => 'Odstránené položky',
+    'recycle_bin_deleted_parent' => 'Nadradené',
+    'recycle_bin_deleted_by' => 'Zmazal(a)',
+    'recycle_bin_deleted_at' => 'Čas odstránenia',
+    'recycle_bin_permanently_delete' => 'Natrvalo odstrániť',
+    'recycle_bin_restore' => 'Obnoviť',
+    'recycle_bin_contents_empty' => 'Kôš je aktuálne prázdny',
+    'recycle_bin_empty' => 'Vyprázdniť Kôš',
+    'recycle_bin_empty_confirm' => 'Tým sa natrvalo odstránia všetky položky v koši vrátane obsahu obsiahnutého v každej položke. Naozaj chcete vyprázdniť kôš?',
+    'recycle_bin_destroy_confirm' => 'Táto akcia natrvalo odstráni túto položku spolu so všetkými podriadenými prvkami uvedenými nižšie zo systému a tento obsah nebudete môcť obnoviť. Naozaj chcete natrvalo odstrániť túto položku?',
+    'recycle_bin_destroy_list' => 'Položky, ktoré budú odstránené',
+    'recycle_bin_restore_list' => 'Položky, ktoré budú obnovené',
+    'recycle_bin_restore_confirm' => 'Táto akcia obnoví odstránenú položku vrátane všetkých podradených prvkov na ich pôvodné miesto. Ak bolo pôvodné umiestnenie medzitým odstránené a teraz je v koši, bude potrebné obnoviť aj nadradenú položku.',
+    'recycle_bin_restore_deleted_parent' => 'Nadradená položka tejto položky bola tiež odstránená. Položka zostane odstránená, kým nebude obnovený aj nadradená položka.',
+    'recycle_bin_restore_parent' => 'Obnoviť nadradenú položku',
+    'recycle_bin_destroy_notification' => 'Vymazané :count položky z koša.',
+    'recycle_bin_restore_notification' => 'Obnovené :count položky z koša.',
 
     // Audit Log
-    'audit' => 'Audit Log',
-    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
-    'audit_event_filter' => 'Event Filter',
-    'audit_event_filter_no_filter' => 'No Filter',
-    'audit_deleted_item' => 'Deleted Item',
-    'audit_deleted_item_name' => 'Name: :name',
-    'audit_table_user' => 'User',
-    'audit_table_event' => 'Event',
-    'audit_table_related' => 'Related Item or Detail',
-    'audit_table_date' => 'Activity Date',
-    'audit_date_from' => 'Date Range From',
-    'audit_date_to' => 'Date Range To',
+    'audit' => 'Denník auditu',
+    'audit_desc' => 'Tento denník auditu zobrazuje zoznam aktivít sledovaných v systéme. Tento zoznam je nefiltrovaný na rozdiel od podobných zoznamov aktivít v systéme, kde sa používajú filtre povolení.',
+    'audit_event_filter' => 'Filter udalostí',
+    'audit_event_filter_no_filter' => 'Žiadny filter',
+    'audit_deleted_item' => 'Odstránená položka',
+    'audit_deleted_item_name' => 'Názov :name',
+    'audit_table_user' => 'Užívateľ',
+    'audit_table_event' => 'Udalosť',
+    'audit_table_related' => 'Súvisiaca položka alebo detail',
+    'audit_table_ip' => 'IP adresa',
+    'audit_table_date' => 'Dátum aktivity',
+    'audit_date_from' => 'Časový interval od',
+    'audit_date_to' => 'Časový interval',
 
     // Role Settings
     'roles' => 'Roly',
@@ -136,19 +139,21 @@ return [
     'role_details' => 'Detaily roly',
     'role_name' => 'Názov roly',
     'role_desc' => 'Krátky popis roly',
-    'role_external_auth_id' => 'External Authentication IDs',
+    'role_mfa_enforced' => 'Vyžadovať viacfaktorové overenie',
+    'role_external_auth_id' => 'Externé autentifikačné ID',
     'role_system' => 'Systémové oprávnenia',
     'role_manage_users' => 'Spravovať používateľov',
     'role_manage_roles' => 'Spravovať role a oprávnenia rolí',
     'role_manage_entity_permissions' => 'Spravovať všetky oprávnenia kníh, kapitol a stránok',
     'role_manage_own_entity_permissions' => 'Spravovať oprávnenia vlastných kníh, kapitol a stránok',
-    'role_manage_page_templates' => 'Manage page templates',
-    'role_access_api' => 'Access system API',
+    'role_manage_page_templates' => 'Spravovať šablóny',
+    'role_access_api' => 'API prístupového systému',
     'role_manage_settings' => 'Spravovať nastavenia aplikácie',
+    'role_export_content' => 'Exportovať obsah',
     'role_asset' => 'Oprávnenia majetku',
-    '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.',
+    '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' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
+    '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_all' => 'Všetko',
     'role_own' => 'Vlastné',
     'role_controlled_by_asset' => 'Regulované zdrojom, do ktorého sú nahrané',
@@ -162,67 +167,99 @@ return [
     'user_profile' => 'Profil používateľa',
     'users_add_new' => 'Pridať nového používateľa',
     'users_search' => 'Hľadať medzi používateľmi',
-    'users_latest_activity' => 'Latest Activity',
-    'users_details' => 'User Details',
-    'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
-    'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
+    'users_latest_activity' => 'Nedávna aktivita',
+    'users_details' => 'Údaje o používateľovi',
+    'users_details_desc' => 'Nastavte zobrazované meno a e-mailovú adresu pre tohto používateľa. E-mailová adresa bude slúžiť na prihlásenie do aplikácie.',
+    'users_details_desc_no_email' => 'Nastavte zobrazované meno pre tohto používateľa, aby ho ostatní mohli rozpoznať.',
     'users_role' => 'Používateľské roly',
-    'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
-    'users_password' => 'User Password',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
-    'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
-    'users_send_invite_option' => 'Send user invite email',
+    'users_role_desc' => 'Vyberte, ku ktorým rolám bude tento používateľ priradený. Ak je používateľ priradený k viacerým rolám, povolenia z týchto rolí sa nahromadia a získajú všetky schopnosti priradených rolí.',
+    'users_password' => 'Heslo používateľa',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
+    'users_send_invite_text' => 'Môžete sa rozhodnúť poslať tomuto používateľovi e-mail s pozvánkou, ktorý mu umožní nastaviť si vlastné heslo, v opačnom prípade mu ho môžete nastaviť sami.',
+    'users_send_invite_option' => 'Odoslať e-mail s pozvánkou pre používateľa',
     'users_external_auth_id' => 'Externé autentifikačné ID',
-    'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
+    'users_external_auth_id_desc' => 'Toto je ID používané na priradenie tohto používateľa pri komunikácii s vaším externým autentifikačným systémom.',
     'users_password_warning' => 'Pole nižšie vyplňte iba ak chcete zmeniť heslo:',
     'users_system_public' => 'Tento účet reprezentuje každého hosťovského používateľa, ktorý navštívi Vašu inštanciu. Nedá sa pomocou neho prihlásiť a je priradený automaticky.',
     'users_delete' => 'Zmazať používateľa',
     'users_delete_named' => 'Zmazať používateľa :userName',
     'users_delete_warning' => ' Toto úplne odstráni používateľa menom \':userName\' zo systému.',
     'users_delete_confirm' => 'Ste si istý, že chcete zmazať tohoto používateľa?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
-    'users_none_selected' => 'No user selected',
-    'users_delete_success' => 'User successfully removed',
+    'users_migrate_ownership' => 'Migrovať vlastníctvo',
+    'users_migrate_ownership_desc' => 'Tu vyberte používateľa, ak chcete, aby sa vlastníkom všetkých položiek aktuálne vlastnených týmto používateľom stal iný používateľ.',
+    'users_none_selected' => 'Nie je vybratý žiadny používateľ',
+    'users_delete_success' => 'Používateľ úspešne zmazaný',
     'users_edit' => 'Upraviť používateľa',
     'users_edit_profile' => 'Upraviť profil',
     'users_edit_success' => 'Používateľ úspešne upravený',
     'users_avatar' => 'Avatar používateľa',
     'users_avatar_desc' => 'Tento obrázok by mal byť štvorec s rozmerom približne 256px.',
     'users_preferred_language' => 'Preferovaný jazyk',
-    'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.',
+    'users_preferred_language_desc' => 'Táto možnosť zmení jazyk používaný pre používateľské rozhranie aplikácie. Neovplyvní to žiadny obsah vytvorený používateľmi.',
     'users_social_accounts' => 'Sociálne účty',
     'users_social_accounts_info' => 'Tu si môžete pripojiť iné účty pre rýchlejšie a jednoduchšie prihlásenie. Disconnecting an account here does not previously authorized access. Revoke access from your profile settings on the connected social account.',
     'users_social_connect' => 'Pripojiť účet',
     'users_social_disconnect' => 'Odpojiť účet',
     'users_social_connected' => ':socialAccount účet bol úspešne pripojený k Vášmu profilu.',
     'users_social_disconnected' => ':socialAccount účet bol úspešne odpojený od Vášho profilu.',
-    'users_api_tokens' => 'API Tokens',
-    'users_api_tokens_none' => 'No API tokens have been created for this user',
-    'users_api_tokens_create' => 'Create Token',
-    'users_api_tokens_expires' => 'Expires',
-    'users_api_tokens_docs' => 'API Documentation',
+    'users_api_tokens' => 'API Kľúče',
+    'users_api_tokens_none' => 'Pre tohto používateľa neboli vytvorené žiadne tokeny API',
+    'users_api_tokens_create' => 'Vytvoriť token',
+    'users_api_tokens_expires' => 'Platnosť do',
+    'users_api_tokens_docs' => 'Dokumentácia API',
+    'users_mfa' => 'Viacstupňové overovanie',
+    'users_mfa_desc' => 'Pre vyššiu úroveň bezpečnosti si nastavte viacúrovňové prihlasovanie.',
+    'users_mfa_x_methods' => ':count nakonfigurované metódy|:count nakonfigurovaných metód',
+    'users_mfa_configure' => 'Konfigurovať metódy',
 
     // API Tokens
-    'user_api_token_create' => 'Create API Token',
-    'user_api_token_name' => 'Name',
-    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
-    'user_api_token_expiry' => 'Expiry Date',
-    'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
-    'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
-    'user_api_token_create_success' => 'API token successfully created',
-    'user_api_token_update_success' => 'API token successfully updated',
+    'user_api_token_create' => 'Vytvoriť API token',
+    'user_api_token_name' => 'Názov',
+    'user_api_token_name_desc' => 'Dajte svojmu tokenu čitateľný názov ako budúcu pripomienku jeho zamýšľaného účelu.',
+    'user_api_token_expiry' => 'Dátum expirácie',
+    'user_api_token_expiry_desc' => 'Nastavte dátum, kedy platnosť tohto tokenu vyprší. Po tomto dátume už žiadosti uskutočnené pomocou tohto tokenu nebudú fungovať. Ak toto pole ponecháte prázdne, nastaví sa uplynutie platnosti o 100 rokov do budúcnosti.',
+    'user_api_token_create_secret_message' => 'Ihneď po vytvorení tohto tokenu sa vygeneruje a zobrazí "Token ID" a "Token Secret". Kľúč sa zobrazí iba raz, takže pred pokračovaním nezabudnite skopírovať hodnotu na bezpečné a zabezpečené miesto.',
+    'user_api_token_create_success' => 'Kľúč rozhrania API bol úspešne vytvorený',
+    'user_api_token_update_success' => 'Kľúč rozhrania API bol úspešne upravený',
     'user_api_token' => 'API Token',
     'user_api_token_id' => 'Token ID',
-    'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
-    'user_api_token_secret' => 'Token Secret',
-    'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.',
-    'user_api_token_created' => 'Token created :timeAgo',
-    'user_api_token_updated' => 'Token updated :timeAgo',
-    'user_api_token_delete' => 'Delete Token',
-    'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
-    'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
-    'user_api_token_delete_success' => 'API token successfully deleted',
+    'user_api_token_id_desc' => 'Toto je neupraviteľný identifikátor vygenerovaný systémom pre tento token, ktorý bude potrebné poskytnúť v žiadostiach API.',
+    'user_api_token_secret' => 'Kľúč',
+    'user_api_token_secret_desc' => 'Toto je systémom vygenerovaný kľúč pre tento token, ktorý bude potrebné poskytnúť v žiadostiach API. Toto sa zobrazí iba raz, takže túto hodnotu skopírujte na bezpečné a bezpečné miesto.',
+    'user_api_token_created' => 'Token vytvorený :timeAgo',
+    'user_api_token_updated' => 'Token upravený :timeAgo',
+    'user_api_token_delete' => 'Zmazať Token',
+    'user_api_token_delete_warning' => 'Týmto sa tento token API s názvom \':tokenName\' úplne odstráni zo systému.',
+    'user_api_token_delete_confirm' => 'Určite chcete odstrániť tento token?',
+    'user_api_token_delete_success' => 'Kľúč rozhrania API bol úspešne odstránený',
+
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 545313415afbf963738584626e2827ade96a8d09..416c7e8deba92f38dd34050bbf69e15301b14202 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute môže obsahovať iba písmená, čísla a pomlčky.',
     'alpha_num'            => ':attribute môže obsahovať iba písmená a čísla.',
     'array'                => ':attribute musí byť pole.',
+    'backup_codes'         => 'Poskytnutý kód nie je platný alebo už bol použitý.',
     'before'               => ':attribute musí byť dátum pred :date.',
     'between'              => [
         'numeric' => ':attribute musí byť medzi :min a :max.',
@@ -30,40 +31,40 @@ return [
     'digits'               => ':attribute musí mať :digits číslic.',
     'digits_between'       => ':attribute musí mať medzi :min a :max číslicami.',
     'email'                => ':attribute musí byť platná emailová adresa.',
-    'ends_with' => 'The :attribute must end with one of the following: :values',
+    'ends_with' => ':attribute musí končiť jednou z nasledujúcich hodnôt :values',
     'filled'               => 'Políčko :attribute je povinné.',
     'gt'                   => [
-        'numeric' => 'The :attribute must be greater than :value.',
-        'file'    => 'The :attribute must be greater than :value kilobytes.',
-        'string'  => 'The :attribute must be greater than :value characters.',
-        'array'   => 'The :attribute must have more than :value items.',
+        'numeric' => 'Hodnota :attribute musí byť väčšia ako :value.',
+        'file'    => ':attribute musí mať viac kilobajtov ako :value.',
+        'string'  => ':attribute musí mať viac znakov ako :value.',
+        'array'   => ':attribute musí mať viac ako :value položiek.',
     ],
     'gte'                  => [
-        'numeric' => 'The :attribute must be greater than or equal :value.',
-        'file'    => 'The :attribute must be greater than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be greater than or equal :value characters.',
-        'array'   => 'The :attribute must have :value items or more.',
+        'numeric' => 'Hodnota :attribute musí byť väčšia alebo rovná ako :value.',
+        'file'    => ':attribute musí mať rovnaký alebo väčší počet kilobajtov ako :value.',
+        'string'  => ':attribute musí byť väčší alebo rovnaký ako :value znakov.',
+        'array'   => ':attribute musí mať :value položiek alebo viac.',
     ],
     'exists'               => 'Vybraný :attribute nie je platný.',
     'image'                => ':attribute musí byť obrázok.',
-    'image_extension'      => 'The :attribute must have a valid & supported image extension.',
+    'image_extension'      => ':attribute musí mať platné a podporované rozšírenie obrázka.',
     'in'                   => 'Vybraný :attribute je neplatný.',
     'integer'              => ':attribute musí byť celé číslo.',
     'ip'                   => ':attribute musí byť platná IP adresa.',
-    'ipv4'                 => 'The :attribute must be a valid IPv4 address.',
-    'ipv6'                 => 'The :attribute must be a valid IPv6 address.',
-    'json'                 => 'The :attribute must be a valid JSON string.',
+    'ipv4'                 => ':attribute musí byť platná adresa IPv4.',
+    'ipv6'                 => ':attribute musí byť platná IPv6 adresa.',
+    'json'                 => ':attribute musí byť platný JSON reťazec.',
     'lt'                   => [
-        'numeric' => 'The :attribute must be less than :value.',
-        'file'    => 'The :attribute must be less than :value kilobytes.',
-        'string'  => 'The :attribute must be less than :value characters.',
-        'array'   => 'The :attribute must have less than :value items.',
+        'numeric' => 'Hodnota :attribute musí byť menšia ako :value.',
+        'file'    => ':attribute musí mať menej kilobajtov ako :value.',
+        'string'  => ':attribute musí mať menej znakov ako :value.',
+        'array'   => ':attribute musí mať menej položiek ako :value.',
     ],
     'lte'                  => [
-        'numeric' => 'The :attribute must be less than or equal :value.',
-        'file'    => 'The :attribute must be less than or equal :value kilobytes.',
-        'string'  => 'The :attribute must be less than or equal :value characters.',
-        'array'   => 'The :attribute must not have more than :value items.',
+        'numeric' => 'Hodnota :attribute musí byť menšia alebo rovná ako :value.',
+        'file'    => ':attribute musí mať rovnaký alebo menší počet kilobajtov ako :value.',
+        'string'  => ':attribute musí mať rovnaký alebo menší počet znakov ako :value.',
+        'array'   => ':attribute nesmie mať viac ako :value položiek.',
     ],
     'max'                  => [
         'numeric' => ':attribute nesmie byť väčší ako :max.',
@@ -79,7 +80,7 @@ return [
         'array'   => ':attribute musí mať aspoň :min položiek.',
     ],
     'not_in'               => 'Vybraný :attribute je neplatný.',
-    'not_regex'            => 'The :attribute format is invalid.',
+    'not_regex'            => ':attribute formát je neplatný.',
     'numeric'              => ':attribute musí byť číslo.',
     'regex'                => ':attribute formát je neplatný.',
     'required'             => 'Políčko :attribute je povinné.',
@@ -89,7 +90,7 @@ return [
     'required_without'     => 'Políčko :attribute je povinné aj :values neexistuje.',
     'required_without_all' => 'Políčko :attribute je povinné ak ani jedno z :values neexistuje.',
     'same'                 => ':attribute a :other musia byť rovnaké.',
-    'safe_url'             => 'The provided link may not be safe.',
+    'safe_url'             => 'Poskytnutý odkaz nemusí byť bezpečný.',
     'size'                 => [
         'numeric' => ':attribute musí byť :size.',
         'file'    => ':attribute musí mať :size kilobajtov.',
@@ -98,9 +99,10 @@ return [
     ],
     'string'               => ':attribute musí byť reťazec.',
     'timezone'             => ':attribute musí byť plantá časová zóna.',
+    'totp'                 => 'Poskytnutý kód nie je platný alebo už bol použitý.',
     'unique'               => ':attribute je už použité.',
     'url'                  => ':attribute formát je neplatný.',
-    'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
+    'uploaded'             => 'Súbor sa nepodarilo nahrať. Server nemusí akceptovať súbory tejto veľkosti.',
 
     // Custom validation lines
     'custom' => [
index 4ed1185def5c45acebd58adb81c9655d02eda531..3c36c308c27590f358597ea778078296f9c38bcb 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'ustvarjena stran',
-    'page_create_notification'    => 'Stran uspešno ustvarjena',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'posodobljena stran',
-    'page_update_notification'    => 'Stran uspešno posodobljena',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'izbrisana stran',
-    'page_delete_notification'    => 'Stran uspešno izbrisana',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'obnovljena stran',
-    'page_restore_notification'   => 'Stran uspešno obnovljena',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'premaknjena stran',
 
     // Chapters
     'chapter_create'              => 'ustvarjeno poglavje',
-    'chapter_create_notification' => 'Poglavje uspešno ustvarjeno',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'posodobljeno poglavje',
-    'chapter_update_notification' => 'Poglavje uspešno posodobljeno',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'izbrisano poglavje',
-    'chapter_delete_notification' => 'Poglavje uspešno izbrisano',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'premaknjeno poglavje',
 
     // Books
     'book_create'                 => 'knjiga ustvarjena',
-    'book_create_notification'    => 'Knjiga uspešno usvarjena',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'knjiga posodobljena',
-    'book_update_notification'    => 'Knjiga uspešno posodobljena',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'izbrisana knjiga',
-    'book_delete_notification'    => 'Knjiga uspešno izbrisana',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'razvrščena knjiga',
-    'book_sort_notification'      => 'Knjiga uspešno razvrščena',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'knjižna polica izdelana',
-    'bookshelf_create_notification'    => 'Knjižna polica uspešno ustvarjena',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'knjižna polica posodobljena',
-    'bookshelf_update_notification'    => 'Knjižna polica uspešno posodobljena',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'knjižna polica izbrisana',
-    'bookshelf_delete_notification'    => 'Knjižna polica uspešno Izbrisana',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'komentar na',
index df6fb4227dc665ce4edbc3866217f8b38a432d37..bb45fa8651d9bf515580e07cca1d80a9755302f3 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-pošta',
     'password' => 'Geslo',
     'password_confirm' => 'Potrdi geslo',
-    'password_hint' => 'Mora vebovati vsaj 8 znakov',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Pozabljeno geslo?',
     'remember_me' => 'Zapomni si me',
     'ldap_email_hint' => 'Prosimo vpišite e-poštni naslov za ta račun.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Ta e-poštna domena nima dostopa do te aplikacije',
     'register_success' => 'Hvala za registracijo! Sedaj ste registrirani in prijavljeni.',
 
-
     // Password Reset
     'reset_password' => 'Ponastavi geslo',
     'reset_password_send_instructions' => 'Spodaj vpišite vaš e-poštni naslov in prejeli boste e-pošto s povezavo za ponastavitev gesla.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'To e-poštno sporočilo ste prejeli, ker smo prejeli zahtevo za ponastavitev gesla za vaš račun.',
     'email_reset_not_requested' => 'Če niste zahtevali ponastavitve gesla, vam ni potrebno ničesar storiti.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Potrdi svojo e-pošto za :appName',
     'email_confirm_greeting' => 'Hvala ker ste se pridružili :appName!',
     'email_confirm_text' => 'Potrdite svoj e-naslov s klikom spodnjega gumba:',
     'email_confirm_action' => 'Potrdi e-pošto',
     'email_confirm_send_error' => 'E-poštna potrditev je zahtevana ampak sistem ni mogel poslati e-pošte. Kontaktirajte administratorja, da zagotovite, da je e-pošta pravilno nastavljena.',
-    'email_confirm_success' => 'Vaš e-naslov je bil potrjen!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Poslali smo vam potrditveno sporočilo. Prosimo preverite svojo elektronsko pošto.',
 
     'email_not_confirmed' => 'Elektronski naslov ni potrjen',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Dobrodošli na :appName!',
     'user_invite_page_text' => 'Za zaključiti in pridobiti dostop si morate nastaviti geslo, ki bo uporabljeno za prijavo v :appName.',
     'user_invite_page_confirm_button' => 'Potrdi geslo',
-    'user_invite_success' => 'Geslo nastavljeno, sedaj imaš dostop do :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index cadd2ba8821af4bebc2defd78aca719e733b3ad3..25c4f62f7381342f7574a92d0d85f51425739d1f 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Vloga',
     'cover_image' => 'Naslovna slika',
     'cover_image_description' => 'Slika naj bo velika približno 440x250px.',
-    
+
     // Actions
     'actions' => 'Dejanja',
     'view' => 'Pogled',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Ponastavi',
     'remove' => 'Odstrani',
     'add' => 'Dodaj',
+    'configure' => 'Configure',
     'fullscreen' => 'Celozaslonski način',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Možnosti razvrščanja',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Ni aktivnosti za prikaz',
     'no_items' => 'Na voljo ni nobenega elementa',
     'back_to_top' => 'Nazaj na vrh',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Preklopi podrobnosti',
     'toggle_thumbnails' => 'Preklopi sličice',
     'details' => 'Podrobnosti',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Seznam',
     'default' => 'Privzeto',
     'breadcrumb' => 'Pot',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index c5ade055f72cc342ed6f8469b4b5d29bac80de70..fc14cc6d7f7f5805f5810e70a1f18f1dcab64c15 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Slike',
     'my_recent_drafts' => 'Moji nedavni osnutki',
     'my_recently_viewed' => 'Nedavno prikazano',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'Niste si ogledali še nobene strani',
     'no_pages_recently_created' => 'Nedavno ni bila ustvarjena nobena stran',
     'no_pages_recently_updated' => 'Nedavno ni bila posodobljena nobena stran',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Vsebuje spletno datoteko',
     'export_pdf' => 'PDF datoteka (.pdf)',
     'export_text' => 'Navadna besedilna datoteka',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Dovoljenja',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Dovoljenja knjižnih polic',
     'shelves_permissions_updated' => 'Posodobljena dovoljenja knjižnih polic',
     'shelves_permissions_active' => 'Aktivna dovoljenja knjižnih polic',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopiraj dovoljenja na knjige',
     'shelves_copy_permissions' => 'Dovoljenja kopiranja',
     'shelves_copy_permissions_explain' => 'To bo uveljavilo trenutne nastavitve dovoljenj na knjižni polici za vse knjige, ki jih vsebuje ta polica. Pred aktiviranjem zagotovite, da so shranjene vse spremembe dovoljenj te knjižne police.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Nazadnje poglavja',
     'books_sort_show_other' => 'Prikaži druge knjige',
     'books_sort_save' => 'Shrani novo razvrstitev',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Poglavje',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Premakni poglavje',
     'chapters_move_named' => 'Premakni poglavje :chapterName',
     'chapter_move_success' => 'Poglavje premaknjeno v :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Dovoljenja poglavij',
     'chapters_empty' => 'V tem poglavju trenutno ni strani.',
     'chapters_permissions_active' => 'Dovoljenja poglavij so aktivirana',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Nova stran',
     'pages_editing_draft_notification' => 'Trenutno urejate osnutek, ki je bil nazadnje shranjen :timeDiff.',
     'pages_draft_edited_notification' => 'Ta stran je odtlej posodobljena. Priporočamo, da zavržete ta osnutek.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count uporabnikov je začelo urejati to stran',
         'start_b' => ':userName je začel urejati to stran',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Dodajte nekaj oznak za boljšo kategorizacijo vaše vsebine.\nZ dodelitvijo oznake lahko poskrbite za bolj poglobljeno organizacijo.",
     'tags_add' => 'Dodaj drugo oznako',
     'tags_remove' => 'Odstrani to oznako',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Priponke',
     'attachments_explain' => 'Naložite nekaj datotek ali pripnite nekaj povezav, da jih prikažete na vaši strani. Vidne so v stranski orodni vrstici.',
     'attachments_explain_instant_save' => 'Spremembe tukaj so takoj shranjene.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Ali ste prepričani, da želite izbrisati to revizijo?',
     'revision_restore_confirm' => 'Ali ste prepričani da želite obnoviti to revizijo? Vsebina trenutne strani bo zamenjana.',
     'revision_delete_success' => 'Revizija izbrisana',
-    'revision_cannot_delete_latest' => 'Ne morem izbrisati zadnje revizije.'
+    'revision_cannot_delete_latest' => 'Ne morem izbrisati zadnje revizije.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index e291222c3f6be5f22cdbcbb61908c492d4619327..fcfddf50597410f1a5e343c9136726af398442f8 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Nisem našel e-naslova za tega uporabnika v podatkih iz zunanjega sistema za preverjanje pristnosti',
     'saml_invalid_response_id' => 'Zahteva iz zunanjega sistema za preverjanje pristnosti ni prepoznana s strani procesa zagnanega s strani te aplikacije. Pomik nazaj po prijavi je lahko vzrok teh težav.',
     'saml_fail_authed' => 'Prijava z uporabo :system ni uspela, sistem ni zagotovil uspešne avtorizacije',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Akcija ni določena',
     'social_login_bad_response' => "Napaka pri :socialAccount prijavi:\n:error",
     'social_account_in_use' => 'Ta :socialAccount je že v uporabi. Poskusite se prijaviti z :socialAccount možnostjo.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Strani ni mogoče najti',
     'sorry_page_not_found' => 'Oprostite, strani ki jo iščete, ni mogoče najti.',
     'sorry_page_not_found_permission_warning' => 'Če pričakujete, da ta stran obstaja, mogoče nimate pravic ogleda zanjo.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Vrni se domov',
     'error_occurred' => 'Prišlo je do napake',
     'app_down' => ':appName trenutno ni dosegljiva',
index 8373c8cebef6fce4d0f480c93a92e6dfbb7530e9..9173cc50c6ece32e2e5ff87e21c9e5f5998e93f0 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Vzdrževanje',
     'maint_image_cleanup' => 'Odstrani /počisti slike',
-    'maint_image_cleanup_desc' => "Pregleda vsebino strani in revizij ter ugotovi, katere slike in risbe so v uporabi in katere so odvečne. Preden to poženeš, naredi popolno varnostno kopijo podatkovne zbirke in slik.",
+    'maint_image_cleanup_desc' => 'Pregleda vsebino strani in revizij ter ugotovi, katere slike in risbe so v uporabi in katere so odvečne. Preden to poženeš, naredi popolno varnostno kopijo podatkovne zbirke in slik.',
     'maint_delete_images_only_in_revisions' => 'Izbriši tudi slike, ki obstajajo le v starih različicah strani',
     'maint_image_cleanup_run' => 'Zaženi čiščenje',
     'maint_image_cleanup_warning' => 'Najdenih je bilo :count verjetno neuporabljenih slik. Ali si prepričan, da želiš odstraniti izbrane slike?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Koš',
     'recycle_bin_desc' => 'Tu lahko obnovite predmete, ki so bili izbrisani, ali pa jih trajno odstranite s sistema. Ta seznam je nefiltriran, za razliko od podobnih seznamov dejavnosti v sistemu, kjer se uporabljajo filtri dovoljenj.',
     'recycle_bin_deleted_item' => 'Izbrisan element',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Izbrisal uporabnik',
     'recycle_bin_deleted_at' => 'Čas izbrisa',
     'recycle_bin_permanently_delete' => 'Trajno izbrišem?',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Predmeti, ki naj bodo obnovljeni',
     'recycle_bin_restore_confirm' => 'S tem dejanjem boste izbrisani element, vključno z vsemi podrejenimi elementi, obnovili na prvotno mesto. Če je bilo prvotno mesto od takrat izbrisano in je zdaj v košu, bo treba obnoviti tudi nadrejeni element.',
     'recycle_bin_restore_deleted_parent' => 'Nadrejeni element je bil prav tako izbrisan. Dokler se ne obnovi nadrejenega elementa, ni mogoče obnoviti njemu podrejenih elementov.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Izbrisano :count skupno število elementov iz koša.',
     'recycle_bin_restore_notification' => 'Obnovljeno :count skupno število elementov iz koša.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Uporabnik',
     'audit_table_event' => 'Dogodek',
     'audit_table_related' => 'Povezani predmet ali podrobnost',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Datum zadnje dejavnosti',
     'audit_date_from' => 'Časovno obdobje od',
     'audit_date_to' => 'Časovno obdobje do',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Podrobnosti vloge',
     'role_name' => 'Naziv vloge',
     'role_desc' => 'Kratki opis vloge',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Zunanje dokazilo ID',
     'role_system' => 'Sistemska dovoljenja',
     'role_manage_users' => 'Upravljanje uporabnikov',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Uredi predloge',
     'role_access_api' => 'API za dostop do sistema',
     'role_manage_settings' => 'Nastavitve za upravljanje',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Vloge uporabnika',
     'users_role_desc' => 'Izberi vloge, ki bodo dodeljene uporabniku. Če je uporabniku dodeljenih več vlog, se dovoljenja združijo in prejmenjo vsa dovoljenja dodeljenih vlog.',
     'users_password' => 'Uporabniško geslo',
-    'users_password_desc' => 'Nastavite geslo, ki se uporablja za prijavo v aplikacijo. Dolgo mora biti vsaj 6 znakov.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Uporabniku lahko pošljete e-poštno sporočilo s povabilom, ki mu omogoča, da nastavi svoje geslo, ali pa ga nastavite kar sami.',
     'users_send_invite_option' => 'Pošlji uporabniku e-poštno povabilo',
     'users_external_auth_id' => 'Zunanje dokazilo ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Ustvari žeton',
     'users_api_tokens_expires' => 'Poteče',
     'users_api_tokens_docs' => 'API dokumentacija',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Ustvari žeton',
@@ -225,6 +234,34 @@ return [
     'user_api_token_delete_confirm' => 'Ali ste prepričani, da želite izbrisati ta API žeton?',
     'user_api_token_delete_success' => 'API žeton uspešno izbrisan',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -240,13 +277,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -262,6 +302,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 9b1a5ff463877dc18d5a0b554baee3427c2630b0..5b08463ca9e0667075aa527cbe4f8f73e17906d5 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute lahko vsebuje samo ?rke, ?tevilke in ?rtice.',
     'alpha_num'            => ':attribute lahko vsebuje samo črke in številke.',
     'array'                => ':attribute mora biti niz.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute mora biti datum pred :date.',
     'between'              => [
         'numeric' => ':attribute mora biti med :min in :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute mora biti niz.',
     'timezone'             => ':attribute mora biti veljavna cona.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute je že zaseden.',
     'url'                  => ':attribute oblika ni veljavna.',
     'uploaded'             => 'Datoteke ni bilo mogoče naložiti. Strežnik morda ne sprejema datotek te velikosti.',
index e3fa051555f92bd5f24edf72342f110d0b385f84..a292a223afca52d3de07f0c7b28d8e69f101c9db 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'skapade sidan',
-    'page_create_notification'    => 'Sidan har skapats',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'uppdaterade sidan',
-    'page_update_notification'    => 'Sidan har uppdaterats',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'tog bort sidan',
-    'page_delete_notification'    => 'Sidan har tagits bort',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'återställde sidan',
-    'page_restore_notification'   => 'Sidan har återställts',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'flyttade sidan',
 
     // Chapters
     'chapter_create'              => 'skapade kapitlet',
-    'chapter_create_notification' => 'Kapitlet har skapats',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'uppdaterade kapitlet',
-    'chapter_update_notification' => 'Kapitlet har uppdaterats',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'tog bort kapitlet',
-    'chapter_delete_notification' => 'Kapitlet har tagits bort',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'flyttade kapitlet',
 
     // Books
     'book_create'                 => 'skapade boken',
-    'book_create_notification'    => 'Boken har skapats',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'uppdaterade boken',
-    'book_update_notification'    => 'Boken har uppdaterats',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'tog bort boken',
-    'book_delete_notification'    => 'Boken har tagits bort',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorterade boken',
-    'book_sort_notification'      => 'Boken har sorterats om',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'skapade hyllan',
-    'bookshelf_create_notification'    => 'Hyllan har skapats',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'uppdaterade hyllan',
-    'bookshelf_update_notification'    => 'Hyllan har uppdaterats',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'tog bort hyllan',
-    'bookshelf_delete_notification'    => 'Hyllan har tagits bort',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" har lagts till i dina favoriter',
+    'favourite_remove_notification' => '":name" har tagits bort från dina favoriter',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'kommenterade',
index 8669055351722eb127ac674a2a57978f66777ac5..7c0907f51826819f44b869a530d9fd9e07ab674e 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-post',
     'password' => 'Lösenord',
     'password_confirm' => 'Bekräfta lösenord',
-    'password_hint' => 'Måste vara fler än 7 tecken',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Glömt lösenord?',
     'remember_me' => 'Kom ihåg mig',
     'ldap_email_hint' => 'Vänligen ange en e-postadress att använda till kontot.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Den e-postadressen har inte tillgång till den här applikationen',
     'register_success' => 'Tack för din registrering! Du är nu registerad och inloggad.',
 
-
     // Password Reset
     'reset_password' => 'Återställ lösenord',
     'reset_password_send_instructions' => 'Ange din e-postadress nedan så skickar vi ett mail med en länk för att återställa ditt lösenord.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Du får detta mail eftersom vi fått en begäran om att återställa lösenordet till ditt konto.',
     'email_reset_not_requested' => 'Om du inte begärt att få ditt lösenord återställt behöver du inte göra någonting',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Bekräfta din e-post på :appName',
     'email_confirm_greeting' => 'Tack för att du gått med i :appName!',
     'email_confirm_text' => 'Vänligen bekräfta din e-postadress genom att klicka på knappen nedan:',
     'email_confirm_action' => 'Bekräfta e-post',
     'email_confirm_send_error' => 'E-posten behöver bekräftas men systemet kan inte skicka mail. Kontakta adminstratören för att kontrollera att allt är konfigurerat korrekt.',
-    'email_confirm_success' => 'Din e-post har bekräftats',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Bekräftelsemailet har skickats på nytt, kolla din mail',
 
     'email_not_confirmed' => 'E-posadress ej bekräftad',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Välkommen till :appName!',
     'user_invite_page_text' => 'För att slutföra ditt konto och få åtkomst måste du ange ett lösenord som kommer att användas för att logga in på :appName vid framtida besök.',
     'user_invite_page_confirm_button' => 'Bekräfta lösenord',
-    'user_invite_success' => 'Lösenord satt, du har nu tillgång till :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index f820fbdb3a0393fe5bcfbbcb4587d07a924a84ef..dcff1eadd2bf0f6e59736c877ccf60f709a97dac 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Roll',
     'cover_image' => 'Omslagsbild',
     'cover_image_description' => 'Bilden bör vara cirka 440x250px stor.',
-    
+
     // Actions
     'actions' => 'Åtgärder',
     'view' => 'Visa',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Återställ',
     'remove' => 'Radera',
     'add' => 'Lägg till',
+    'configure' => 'Configure',
     'fullscreen' => 'Helskärm',
+    'favourite' => 'Favorit',
+    'unfavourite' => 'Ta bort favorit',
+    'next' => 'Nästa',
+    'previous' => 'Föregående',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sorteringsalternativ',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Sortera stigande',
     'sort_descending' => 'Sortera fallande',
     'sort_name' => 'Namn',
-    'sort_default' => 'Default',
+    'sort_default' => 'Standard',
     'sort_created_at' => 'Skapad',
     'sort_updated_at' => 'Uppdaterad',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Ingen aktivitet att visa',
     'no_items' => 'Inga tillgängliga föremål',
     'back_to_top' => 'Tillbaka till toppen',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Växla detaljer',
     'toggle_thumbnails' => 'Växla miniatyrer',
     'details' => 'Information',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Listvy',
     'default' => 'Förvald',
     'breadcrumb' => 'Brödsmula',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Expandera sidhuvudsmenyn',
     'profile_menu' => 'Profilmeny',
     'view_profile' => 'Visa profil',
     'edit_profile' => 'Redigera profil',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Information',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Flik: Visa sekundär information',
     'tab_content' => 'Innehåll',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Flik: Visa primärt innehåll',
 
     // Email Content
     'email_action_help' => 'Om du har problem, klicka på knappen ":actionText", och kopiera och klistra in den här adressen i din webbläsare:',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Integritetspolicy',
+    'terms_of_service' => 'Användarvillkor',
 ];
index a0df3b608745fcdc4c5a13b26b41f8e2d3a0eef6..c41f7c98ba53eee58e65f89eccf75e6a13175d90 100644 (file)
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => 'Skapad :timeLength av :user',
     'meta_updated' => 'Uppdaterad :timeLength',
     'meta_updated_name' => 'Uppdaterad :timeLength av :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Ägs av :user',
     'entity_select' => 'Välj enhet',
     'images' => 'Bilder',
     'my_recent_drafts' => 'Mina nyaste utkast',
     'my_recently_viewed' => 'Mina senast visade sidor',
+    'my_most_viewed_favourites' => 'Mina mest visade favoriter',
+    'my_favourites' => 'Mina favoriter',
     'no_pages_viewed' => 'Du har inte visat några sidor',
     'no_pages_recently_created' => 'Inga sidor har skapats nyligen',
     'no_pages_recently_updated' => 'Inga sidor har uppdaterats nyligen',
@@ -34,13 +36,14 @@ return [
     'export_html' => 'Webb-fil',
     'export_pdf' => 'PDF-fil',
     'export_text' => 'Textfil',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Rättigheter',
     'permissions_intro' => 'Dessa rättigheter kommer att överskrida eventuella rollbaserade rättigheter.',
     'permissions_enable' => 'Aktivera anpassade rättigheter',
     'permissions_save' => 'Spara rättigheter',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Ägare',
 
     // Search
     'search_results' => 'Sökresultat',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Har anpassade rättigheter',
     'search_created_by_me' => 'Skapade av mig',
     'search_updated_by_me' => 'Uppdaterade av mig',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Ägs av mig',
     'search_date_options' => 'Datumalternativ',
     'search_updated_before' => 'Uppdaterade före',
     'search_updated_after' => 'Uppdaterade efter',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Bokhyllerättigheter',
     'shelves_permissions_updated' => 'Bokhyllerättigheterna har ändrats',
     'shelves_permissions_active' => 'Bokhyllerättigheterna är aktiva',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'Kopiera rättigheter till böcker',
     'shelves_copy_permissions' => 'Kopiera rättigheter',
     'shelves_copy_permissions_explain' => 'Detta kommer kopiera hyllans rättigheter till alla böcker på den. Se till att du har sparat alla ändringar innan du går vidare.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Kapitel sist',
     'books_sort_show_other' => 'Visa andra böcker',
     'books_sort_save' => 'Spara ordning',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Kapitel',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Flytta kapitel',
     'chapters_move_named' => 'Flytta kapitel :chapterName',
     'chapter_move_success' => 'Kapitel flyttat till :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Rättigheter för kapitel',
     'chapters_empty' => 'Det finns inga sidor i det här kapitlet.',
     'chapters_permissions_active' => 'Anpassade rättigheter är i bruk',
@@ -211,7 +219,7 @@ return [
     'pages_revisions' => 'Sidrevisioner',
     'pages_revisions_named' => 'Sidrevisioner för :pageName',
     'pages_revision_named' => 'Sidrevision för :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Återställd från #:id; :summary',
     'pages_revisions_created_by' => 'Skapad av',
     'pages_revisions_date' => 'Revisionsdatum',
     'pages_revisions_number' => '#',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Ny sida',
     'pages_editing_draft_notification' => 'Du redigerar just nu ett utkast som senast sparades :timeDiff.',
     'pages_draft_edited_notification' => 'Denna sida har uppdaterats sen dess. Vi rekommenderar att du förkastar dina ändringar.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count har börjat redigera den här sidan',
         'start_b' => ':userName har börjat redigera den här sidan',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Lägg till taggar för att kategorisera ditt innehåll bättre. \n Du kan tilldela ett värde till en tagg för ännu bättre organisering.",
     'tags_add' => 'Lägg till ännu en tagg',
     'tags_remove' => 'Ta bort denna etikett',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Bilagor',
     'attachments_explain' => 'Ladda upp filer eller bifoga länkar till ditt innehåll. Dessa visas i sidokolumnen.',
     'attachments_explain_instant_save' => 'Ändringar här sparas omgående.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Är du säker på att du vill radera den här versionen?',
     'revision_restore_confirm' => 'Är du säker på att du vill använda denna revision? Det nuvarande innehållet kommer att ersättas.',
     'revision_delete_success' => 'Revisionen raderad',
-    'revision_cannot_delete_latest' => 'Det går inte att ta bort den senaste versionen.'
+    'revision_cannot_delete_latest' => 'Det går inte att ta bort den senaste versionen.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index dbc3f37f237021350066bfe1fc36a29e4be4566e..28dff2559b50d2d1cd90e263bd8c0fe1f5bbe873 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Kunde inte hitta en e-postadress för den här användaren i data som tillhandahålls av det externa autentiseringssystemet',
     'saml_invalid_response_id' => 'En begäran från det externa autentiseringssystemet känns inte igen av en process som startats av denna applikation. Att navigera bakåt efter en inloggning kan orsaka detta problem.',
     'saml_fail_authed' => 'Inloggning med :system misslyckades, systemet godkände inte auktoriseringen',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Ingen åtgärd definierad',
     'social_login_bad_response' => "Ett fel inträffade vid inloggning genom :socialAccount: \n:error",
     'social_account_in_use' => 'Detta konto från :socialAccount används redan. Testa att logga in med :socialAccount istället.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Sidan hittades inte',
     'sorry_page_not_found' => 'Tyvärr gick det inte att hitta sidan du söker.',
     'sorry_page_not_found_permission_warning' => 'Om du förväntade dig att denna sida skulle existera, kanske du inte har behörighet att se den.',
+    'image_not_found' => 'Bilden hittades inte',
+    'image_not_found_subtitle' => 'Tyvärr gick det inte att hitta bilden du letade efter.',
+    'image_not_found_details' => 'Om du förväntade dig att den här bilden skulle finnas kan den ha tagits bort.',
     'return_home' => 'Återvänd till startsidan',
     'error_occurred' => 'Ett fel inträffade',
     'app_down' => ':appName är nere just nu',
index 07b1b5d4a19f02b4321631aa240258e4201c9986..fa4d26c8cdae9889450724dfb74f35776e5833cb 100644 (file)
@@ -37,11 +37,11 @@ return [
     'app_homepage' => 'Startsida',
     'app_homepage_desc' => 'Välj en sida att använda som startsida istället för standardvyn. Den valda sidans rättigheter kommer att ignoreras.',
     'app_homepage_select' => 'Välj en sida',
-    'app_footer_links' => 'Footer Links',
-    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
-    'app_footer_links_label' => 'Link Label',
-    'app_footer_links_url' => 'Link URL',
-    'app_footer_links_add' => 'Add Footer Link',
+    'app_footer_links' => 'Sidfotslänkar',
+    'app_footer_links_desc' => 'Lägg till länkar som visas i sidfoten. Dessa kommer att visas längst ner på de flesta sidor, inklusive de som inte kräver inloggning. Du kan använda en etikett av "trans::<key>" för att använda systemdefinierade översättningar. Exempelvis översätts "trans::common.privacy_policy" till "Integritetspolicy" och "trans::common.terms_of_service" till "Användarvillkor".',
+    'app_footer_links_label' => 'Länketikett',
+    'app_footer_links_url' => 'Länk URL',
+    'app_footer_links_add' => 'Lägg till sidfotslänk',
     'app_disable_comments' => 'Inaktivera kommentarer',
     'app_disable_comments_toggle' => 'Inaktivera kommentarer',
     'app_disable_comments_desc' => 'Inaktivera kommentarer på alla sidor i applikationen. Befintliga kommentarer visas inte.',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Underhåll',
     'maint_image_cleanup' => 'Rensa bilder',
-    'maint_image_cleanup_desc' => "Söker igenom innehåll i sidor & revisioner för att se vilka bilder och teckningar som är i bruk och vilka som är överflödiga. Se till att ta en komplett backup av databas och bilder innan du kör detta.",
+    'maint_image_cleanup_desc' => 'Söker igenom innehåll i sidor & revisioner för att se vilka bilder och teckningar som är i bruk och vilka som är överflödiga. Se till att ta en komplett backup av databas och bilder innan du kör detta.',
     'maint_delete_images_only_in_revisions' => 'Ta också bort bilder som bara finns i gamla sidrevideringar',
     'maint_image_cleanup_run' => 'Kör rensning',
     'maint_image_cleanup_warning' => 'Hittade :count bilder som potentiellt inte används. Vill du verkligen ta bort dessa bilder?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Papperskorgen',
     'recycle_bin_desc' => 'Här kan du återställa objekt som har tagits bort eller välja att permanent ta bort dem från systemet. Denna lista är ofiltrerad till skillnad från liknande aktivitetslistor i systemet där behörighetsfilter tillämpas.',
     'recycle_bin_deleted_item' => 'Raderat objekt',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Borttagen av',
     'recycle_bin_deleted_at' => 'Tid för borttagning',
     'recycle_bin_permanently_delete' => 'Radera permanent',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Objekt som ska återställas',
     'recycle_bin_restore_confirm' => 'Denna åtgärd kommer att återställa det raderade objektet, inklusive alla underordnade element, till deras ursprungliga plats. Om den ursprungliga platsen har tagits bort sedan dess, och är nu i papperskorgen, kommer det överordnade objektet också att behöva återställas.',
     'recycle_bin_restore_deleted_parent' => 'Föräldern till det här objektet har också tagits bort. Dessa kommer att förbli raderade tills den förälder är återställd.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Raderade :count totala objekt från papperskorgen.',
     'recycle_bin_restore_notification' => 'Återställt :count totala objekt från papperskorgen.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Användare',
     'audit_table_event' => 'Händelse',
     'audit_table_related' => 'Relaterat objekt eller detalj',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Datum för senaste aktiviteten',
     'audit_date_from' => 'Datumintervall från',
     'audit_date_to' => 'Datumintervall till',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Om rollen',
     'role_name' => 'Rollens namn',
     'role_desc' => 'Kort beskrivning av rollen',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Externa autentiserings-ID:n',
     'role_system' => 'Systemrättigheter',
     'role_manage_users' => 'Hanter användare',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Hantera mallar',
     'role_access_api' => 'Åtkomst till systemets API',
     'role_manage_settings' => 'Hantera appinställningar',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Användarroller',
     'users_role_desc' => 'Välj vilka roller den här användaren ska tilldelas. Om en användare har tilldelats flera roller kommer behörigheterna från dessa roller att staplas och de kommer att få alla rättigheter i de tilldelade rollerna.',
     'users_password' => 'Användarlösenord',
-    'users_password_desc' => 'Ange ett lösenord som ska användas för att logga in på sidan. Lösenordet måste vara minst 5 tecken långt.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Du kan välja att skicka denna användare ett e-postmeddelande som tillåter dem att ställa in sitt eget lösenord, eller så kan du ställa in deras lösenord själv.',
     'users_send_invite_option' => 'Skicka e-post med inbjudan',
     'users_external_auth_id' => 'Externt ID för autentisering',
@@ -180,10 +185,10 @@ return [
     'users_delete_named' => 'Ta bort användaren :userName',
     'users_delete_warning' => 'Detta kommer att ta bort användaren \':userName\' från systemet helt och hållet.',
     'users_delete_confirm' => 'Är du säker på att du vill ta bort användaren?',
-    'users_migrate_ownership' => 'Migrate Ownership',
-    'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
-    'users_none_selected' => 'No user selected',
-    'users_delete_success' => 'User successfully removed',
+    'users_migrate_ownership' => 'Överför ägarskap',
+    'users_migrate_ownership_desc' => 'Välj en användare här om du vill att en annan användare ska bli ägare till alla objekt som för närvarande ägs av denna användare.',
+    'users_none_selected' => 'Ingen användare vald',
+    'users_delete_success' => 'Användaren har tagits bort',
     'users_edit' => 'Redigera användare',
     'users_edit_profile' => 'Redigera profil',
     'users_edit_success' => 'Användaren har uppdaterats',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Skapa token',
     'users_api_tokens_expires' => 'Förfaller',
     'users_api_tokens_docs' => 'API-dokumentation',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Skapa API-nyckel',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Är du säker på att du vill ta bort denna API-token?',
     'user_api_token_delete_success' => 'API-token har tagits bort',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -232,20 +269,23 @@ return [
         'ar' => 'العربية',
         'bg' => 'Bǎlgarski',
         'bs' => 'Bosanski',
-        'ca' => 'Català',
+        'ca' => 'Katalanska',
         'cs' => 'Česky',
         'da' => 'Danska',
         'de' => 'Deutsch (Sie)',
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index da39796bc388e6ad11b8ea6a273e3ada6c2c7f75..0c9cc3164157faed9ed03dea078edf53496c171f 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute får bara innehålla bokstäver, siffror och bindestreck.',
     'alpha_num'            => ':attribute får bara innehålla bokstäver och siffror.',
     'array'                => ':attribute måste vara en array.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute måste vara före :date.',
     'between'              => [
         'numeric' => ':attribute måste vara mellan :min och :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute måste vara en sträng.',
     'timezone'             => ':attribute måste vara en giltig tidszon.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute är upptaget',
     'url'                  => 'Formatet på :attribute är ogiltigt.',
     'uploaded'             => 'Filen kunde inte laddas upp. Servern kanske inte tillåter filer med denna storlek.',
index d64fce93a62d90889b2297a9e4f6482ad9046475..8ff408021097b9bb80746e2bb03d50b24191eae5 100644 (file)
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'That email domain does not have access to this application',
     'register_success' => 'Thanks for signing up! You are now registered and signed in.',
 
-
     // Password Reset
     'reset_password' => 'Reset Password',
     'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.',
@@ -49,7 +48,6 @@ return [
     '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.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Confirm your email on :appName',
     'email_confirm_greeting' => 'Thanks for joining :appName!',
@@ -73,5 +71,5 @@ return [
     'user_invite_page_welcome' => 'Welcome to :appName!',
     'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
     'user_invite_page_confirm_button' => 'Confirm Password',
-    'user_invite_success' => 'Password set, you now have access to :appName!'
-];
\ No newline at end of file
+    'user_invite_success' => 'Password set, you now have access to :appName!',
+];
index e87bd11a5e343173fadf78e62a042e1ca0579a4a..e3bec68373dbad5c3ae006f9a1d2e99f945d2299 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Role',
     'cover_image' => 'Cover image',
     'cover_image_description' => 'This image should be approx 440x250px.',
-    
+
     // Actions
     'actions' => 'Actions',
     'view' => 'View',
index f64867a56c31736a1730d58c51f3fe0c088364d1..67c451319ac7df8352f8ccc8bcafabb0b5f97abb 100644 (file)
@@ -312,5 +312,5 @@ return [
     'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
     'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
     'revision_delete_success' => 'Revision deleted',
-    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
-];
\ No newline at end of file
+    'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
+];
index 2bd314cf0f28561f9a2f296b373483df42480f89..c2e4ee734fa4d14deb8eb1cb3242019a8bee7236 100644 (file)
@@ -67,7 +67,7 @@ return [
     // Maintenance settings
     'maint' => 'Maintenance',
     'maint_image_cleanup' => 'Cleanup Images',
-    'maint_image_cleanup_desc' => "Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.",
+    'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
     'maint_image_cleanup_ignore_revisions' => 'Ignore images in revisions',
     'maint_image_cleanup_run' => 'Run Cleanup',
     'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
@@ -224,6 +224,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 67f9653f432c1975a7164bf509cce8d6b1611f0f..6d8a5d83845bbdad6b3213c54bb8013ab42e4522 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'sayfa oluşturdu',
-    'page_create_notification'    => 'Sayfa Başarıyla Oluşturuldu',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'sayfayı güncelledi',
-    'page_update_notification'    => 'Sayfa Başarıyla Güncellendi',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'sayfayı sildi',
-    'page_delete_notification'    => 'Sayfa Başarıyla Silindi',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'sayfayı eski haline getirdi',
-    'page_restore_notification'   => 'Sayfa Başarıyla Eski Haline Getirildi',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'sayfayı taşıdı',
 
     // Chapters
     'chapter_create'              => 'bölüm oluşturdu',
-    'chapter_create_notification' => 'Bölüm Başarıyla Oluşturuldu',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'bölümü güncelledi',
-    'chapter_update_notification' => 'Bölüm Başarıyla Güncellendi',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'bölümü sildi',
-    'chapter_delete_notification' => 'Bölüm Başarıyla Silindi',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'bölümü taşıdı',
 
     // Books
     'book_create'                 => 'kitap oluşturdu',
-    'book_create_notification'    => 'Kitap Başarıyla Oluşturuldu',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'kitabı güncelledi',
-    'book_update_notification'    => 'Kitap Başarıyla Güncellendi',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'kitabı sildi',
-    'book_delete_notification'    => 'Kitap Başarıyla Silindi',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'kitabı sıraladı',
-    'book_sort_notification'      => 'Kitap Başarıyla Yeniden Sıralandı',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'kitaplık oluşturdu',
-    'bookshelf_create_notification'    => 'Kitaplık Başarıyla Oluşturuldu',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'kitaplığı güncelledi',
-    'bookshelf_update_notification'    => 'Kitaplık Başarıyla Güncellendi',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'kitaplığı sildi',
-    'bookshelf_delete_notification'    => 'Kitaplık Başarıyla Silindi',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" favorilerinize eklendi',
+    'favourite_remove_notification' => '":name" favorilerinizden çıkarıldı',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'yorum yaptı',
index 6a0e2c1b5ea6bce020997932adb93ebb4699f395..8a5d25a2aa33315a29300f99f007df50f80910e7 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'E-posta',
     'password' => 'Şifre',
     'password_confirm' => 'Şifreyi Onaylayın',
-    'password_hint' => 'En az 8 karakter olmalı',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Şifrenizi mi unuttunuz?',
     'remember_me' => 'Beni Hatırla',
     'ldap_email_hint' => 'Bu hesap için kullanmak istediğiniz e-posta adresini giriniz.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Bu e-posta sağlayıcısının uygulamaya erişim izni bulunmuyor',
     'register_success' => 'Kaydolduğunuz için teşekkürler! Artık kayıtlı bir kullanıcı olarak giriş yaptınız.',
 
-
     // Password Reset
     'reset_password' => 'Şifreyi Sıfırla',
     'reset_password_send_instructions' => 'Aşağıya gireceğiniz e-posta adresine şifre sıfırlama bağlantısı gönderilecektir.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Hesap şifrenizi sıfırlama isteğinde bulunduğunuz için bu e-postayı aldınız.',
     'email_reset_not_requested' => 'Şifre sıfırlama isteğinde bulunmadıysanız herhangi bir işlem yapmanıza gerek yoktur.',
 
-
     // Email Confirmation
     'email_confirm_subject' => ':appName için girdiğiniz e-posta adresini doğrulayın',
     'email_confirm_greeting' => ':appName uygulamasına katıldığınız için teşekkürler!',
     'email_confirm_text' => 'Lütfen aşağıdaki butona tıklayarak e-posta adresinizi doğrulayın:',
     'email_confirm_action' => 'E-posta Adresini Doğrula',
     'email_confirm_send_error' => 'E-posta adresinin doğrulanması gerekiyor fakat sistem, doğrulama bağlantısını göndermeyi başaramadı. E-posta adresinin doğru bir şekilde ayarlığından emin olmak için yöneticiyle iletişime geçin.',
-    'email_confirm_success' => 'E-posta adresiniz doğrulandı!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Doğrulama e-postası tekrar gönderildi, lütfen gelen kutunuzu kontrol ediniz.',
 
     'email_not_confirmed' => 'E-posta Adresi Doğrulanmadı',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => ':appName uygulamasına hoş geldiniz!',
     'user_invite_page_text' => 'Hesap kurulumunuzu tamamlamak ve gelecekteki :appName ziyaretlerinizde hesabınıza erişim sağlayabilmeniz için bir şifre belirlemeniz gerekiyor.',
     'user_invite_page_confirm_button' => 'Şifreyi Onayla',
-    'user_invite_success' => 'Şifreniz ayarlandı, artık :appName uygulamasına giriş yapabilirsiniz!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Zaten yapılandırıldı',
+    'mfa_setup_reconfigure' => 'Yeniden yapılandır',
+    'mfa_setup_remove_confirmation' => '2 adımlı doğrulamayı kaldırmak istediğinize emin misiniz?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobil Uygulama',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => '2 adımlı doğrulama ayarlandı, Lütfen 2 adımlı doğrulama kullanarak yeniden giriş yapınız.',
+];
index ff253493a33947cf51f61b28d14a67782793f6db..7ecf247e126d218f9915a06197cc9e675efb5f80 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Rol',
     'cover_image' => 'Kapak resmi',
     'cover_image_description' => 'Bu görsel yaklaşık 440x250px boyutlarında olmalıdır.',
-    
+
     // Actions
     'actions' => 'İşlemler',
     'view' => 'Görüntüle',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Sıfırla',
     'remove' => 'Kaldır',
     'add' => 'Ekle',
+    'configure' => 'Configure',
     'fullscreen' => 'Tam Ekran',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sıralama Seçenekleri',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Gösterilecek eylem bulunamadı',
     'no_items' => 'Herhangi bir öge bulunamadı',
     'back_to_top' => 'Başa dön',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Detayları Göster/Gizle',
     'toggle_thumbnails' => 'Ön İzleme Görsellerini Göster/Gizle',
     'details' => 'Detaylar',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Liste Görünümü',
     'default' => 'Varsayılan',
     'breadcrumb' => 'Gezinti Menüsü',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
index 64a0ee35268ed2df9ca355ea59eccbf800515c02..738f885196c059c42f23814401d9d480d4760033 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Görseller',
     'my_recent_drafts' => 'Son Taslaklarım',
     'my_recently_viewed' => 'Son Görüntülediklerim',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     'no_pages_viewed' => 'Herhangi bir sayfa görüntülemediniz',
     'no_pages_recently_created' => 'Yakın zamanda bir sayfa oluşturulmadı',
     'no_pages_recently_updated' => 'Yakın zamanda bir sayfa güncellenmedi',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Web Dosyası',
     'export_pdf' => 'PDF Dosyası',
     'export_text' => 'Düz Metin Dosyası',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'İzinler',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Kitaplık İzinleri',
     'shelves_permissions_updated' => 'Kitaplık İzinleri Güncellendi',
     'shelves_permissions_active' => 'Kitaplık İzinleri Aktif',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => 'İzinleri Kitaplara Kopyala',
     'shelves_copy_permissions' => 'İzinleri Kopyala',
     'shelves_copy_permissions_explain' => 'Bu işlem sonucunda kitaplığınızın izinleri, içerdiği kitaplara da aynen uygulanır. Aktifleştirmeden önce bu kitaplığa ait izinleri kaydettiğinizden emin olun.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'En Son Bölümler',
     'books_sort_show_other' => 'Diğer Kitapları Göster',
     'books_sort_save' => 'Yeni Düzeni Kaydet',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Bölüm',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Bölümü Taşı',
     'chapters_move_named' => ':chapterName Bölümünü Taşı',
     'chapter_move_success' => 'Bölüm, :bookName kitabına taşındı',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Bölüm İzinleri',
     'chapters_empty' => 'Bu bölümde henüz bir sayfa bulunmuyor.',
     'chapters_permissions_active' => 'Bölüm İzinleri Aktif',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Yeni Sayfa',
     'pages_editing_draft_notification' => 'Şu anda en son :timeDiff tarihinde kaydedilmiş olan taslağı düzenliyorsunuz.',
     'pages_draft_edited_notification' => 'Bu sayfa o zamandan bu zamana güncellenmiş, bu nedenle bu taslağı yok saymanız önerilir.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count kullanıcı, bu sayfayı düzenlemeye başladı',
         'start_b' => ':userName, bu sayfayı düzenlemeye başladı',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "İçeriğinizi daha iyi kategorize etmek için etiket ekleyin. Etiketlere değer atayarak daha derinlemesine bir düzen elde edebilirsiniz.",
     'tags_add' => 'Başka etiket ekle',
     'tags_remove' => 'Bu etiketi sil',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Ekler',
     'attachments_explain' => 'Sayfanızda göstermek için dosyalar yükleyin veya bağlantılar ekleyin. Bunlar, sayfaya ait yan menüde gösterilecektir.',
     'attachments_explain_instant_save' => 'Burada yapılan değişiklikler anında kaydedilir.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Bu revizyonu silmek istediğinize emin misiniz?',
     'revision_restore_confirm' => 'Bu revizyonu yeniden yüklemek istediğinize emin misiniz? Sayfanın şu anki içeriği değiştirilecektir.',
     'revision_delete_success' => 'Revizyon silindi',
-    'revision_cannot_delete_latest' => 'Son revizyonu silemezsiniz.'
+    'revision_cannot_delete_latest' => 'Son revizyonu silemezsiniz.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index d3a24622c2c8eb73bba91c3c5a4a97155aa27154..2b1ac4c6469a10417921d32d934ce8be27e2adc9 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Harici kimlik doğrulama sisteminden gelen veriler, bu kullanıcının e-posta adresini içermiyor',
     'saml_invalid_response_id' => 'Harici doğrulama sistemi tarafından sağlanan bir veri talebi, bu uygulama tarafından başlatılan bir işlem tarafından tanınamadı. Giriş yaptıktan sonra geri dönmek bu soruna yol açmış olabilir.',
     'saml_fail_authed' => ':system kullanarak giriş yapma başarısız oldu; sistem, başarılı bir kimlik doğrulama sağlayamadı',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Herhangi bir eylem tanımlanmamış',
     'social_login_bad_response' => ":socialAccount girişi sırasında bir hata meydana geldi: \n:error",
     'social_account_in_use' => 'Bu :socialAccount zaten kullanımda, :socialAccount hesabıyla giriş yapmayı deneyin.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Sayfa Bulunamadı',
     'sorry_page_not_found' => 'Üzgünüz, aradığınız sayfa bulunamıyor.',
     'sorry_page_not_found_permission_warning' => 'Bu sayfanın var olduğunu düşünüyorsanız, görüntüleme iznine sahip olmayabilirsiniz.',
+    'image_not_found' => 'Image Not Found',
+    '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' => 'Ana sayfaya dön',
     'error_occurred' => 'Bir Hata Oluştu',
     'app_down' => ':appName şu anda erişilemez durumda',
index 8ba1192c75094dc0deda77923ee19b8d2a687d8b..962cedb8278b03e1ce8ceeeebe61dda98519a657 100755 (executable)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Bakım',
     'maint_image_cleanup' => 'Görselleri Temizle',
-    'maint_image_cleanup_desc' => "Sayfaları ve revizyon içeriklerini tarayarak hangi görsellerin ve çizimlerin kullanımda olduğunu ve hangilerinin gereksiz olduğunu tespit eder. Bunu başlatmadan önce veritabanının ve görsellerin tam bir yedeğinin alındığından emin olun.",
+    'maint_image_cleanup_desc' => 'Sayfaları ve revizyon içeriklerini tarayarak hangi görsellerin ve çizimlerin kullanımda olduğunu ve hangilerinin gereksiz olduğunu tespit eder. Bunu başlatmadan önce veritabanının ve görsellerin tam bir yedeğinin alındığından emin olun.',
     'maint_delete_images_only_in_revisions' => 'Eski sayfa revizyonlarındaki görselleri de sil',
     'maint_image_cleanup_run' => 'Temizliği Başlat',
     'maint_image_cleanup_warning' => 'Muhtemelen kullanılmayan :count adet görsel bulundu. Bu görselleri silmek istediğinize emin misiniz?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Geri Dönüşüm Kutusu',
     'recycle_bin_desc' => 'Burada silinen öğeleri geri yükleyebilir veya bunları sistemden kalıcı olarak kaldırmayı seçebilirsiniz. Bu liste, izin filtrelerinin uygulandığı sistemdeki benzer etkinlik listelerinden farklı olarak filtrelenmez.',
     'recycle_bin_deleted_item' => 'Silinen öge',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Tarafından silindi',
     'recycle_bin_deleted_at' => 'Silinme Zamanı',
     'recycle_bin_permanently_delete' => 'Kalıcı Olarak Sil',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Geri Yüklenecek Öğeler',
     'recycle_bin_restore_confirm' => 'Bu eylem, tüm alt öğeler dahil olmak üzere silinen öğeyi orijinal konumlarına geri yükleyecektir. Orijinal konum o zamandan beri silinmişse ve şimdi geri dönüşüm kutusunda bulunuyorsa, üst öğenin de geri yüklenmesi gerekecektir.',
     'recycle_bin_restore_deleted_parent' => 'Bu öğenin üst öğesi de silindi. Bunlar, üst öğe de geri yüklenene kadar silinmiş olarak kalacaktır.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Kullanıcı',
     'audit_table_event' => 'Etkinlik',
     'audit_table_related' => 'İlgili Öğe veya Detay',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Aktivite Tarihi',
     'audit_date_from' => 'Tarih Aralığından',
     'audit_date_to' => 'Tarih Aralığına',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Rol Detayları',
     'role_name' => 'Rol Adı',
     'role_desc' => 'Rolün Kısa Tanımı',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Harici Doğrulama Kimlikleri',
     'role_system' => 'Sistem Yetkileri',
     'role_manage_users' => 'Kullanıcıları yönet',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Sayfa şablonlarını yönet',
     'role_access_api' => 'Sistem programlama arayüzüne (API) eriş',
     'role_manage_settings' => 'Uygulama ayarlarını yönet',
+    'role_export_content' => 'Export content',
     '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.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Kullanıcı Rolleri',
     'users_role_desc' => 'Bu kullanıcının hangi rollere atanacağını belirleyin. Birden fazla role sahip kullanıcılar, atandığı bütün rollerin yetkilerine sahip olurlar.',
     'users_password' => 'Kullanıcı Şifresi',
-    'users_password_desc' => 'Kullanıcının giriş yaparken kullanacağı bir şifre belirleyin. Şifre en az 6 karakterden oluşmalıdır.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Bu kullanıcıya kendi şifresini belirleyebilmesi için bir davetiye e-postası gönderebilir ya da kullanıcının şifresini kendiniz belirleyebilirsiniz.',
     'users_send_invite_option' => 'Kullanıcıya davetiye e-postası gönder',
     'users_external_auth_id' => 'Harici Doğrulama Kimliği',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Anahtar Oluştur',
     'users_api_tokens_expires' => 'Bitiş süresi',
     'users_api_tokens_docs' => 'API Dokümantasyonu',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'API Anahtarı Oluştur',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Bu API anahtarını silmek istediğinize emin misiniz?',
     'user_api_token_delete_success' => 'API anahtarı başarıyla silindi',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'İbranice',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 48bbef92b20b316a7ed926979ddc62290d9f5f80..9cd8093d483cf5af980cb9c93093653b5648326d 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute sadece harf, rakam ve tirelerden oluşabilir.',
     'alpha_num'            => ':attribute sadece harflerden ve rakamlardan oluşabilir.',
     'array'                => ':attribute bir dizi olmalıdır.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute tarihi, :date tarihinden önceki bir tarih olmalıdır.',
     'between'              => [
         'numeric' => ':attribute değeri, :min ve :max değerleri arasında olmalıdır.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute, string olmalıdır.',
     'timezone'             => ':attribute, geçerli bir bölge olmalıdır.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute zaten alınmış.',
     'url'                  => ':attribute formatı geçersiz.',
     'uploaded'             => 'Dosya yüklemesi başarısız oldu. Sunucu, bu boyuttaki dosyaları kabul etmiyor olabilir.',
index f16f6fe3edc0fd3b37941cc77d79a0aeaa5e795c..b074cb603d1abc4f5461047ea0c2f7fe28c9896c 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'створив сторінку',
-    'page_create_notification'    => 'Сторінка успішно створена',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'оновив сторінку',
-    'page_update_notification'    => 'Сторінка успішно оновлена',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'видалив сторінку',
-    'page_delete_notification'    => 'Сторінка успішно видалена',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'відновив сторінку',
-    'page_restore_notification'   => 'Сторінка успішно відновлена',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'перемістив сторінку',
 
     // Chapters
     'chapter_create'              => 'створив розділ',
-    'chapter_create_notification' => 'Розділ успішно створено',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'оновив розділ',
-    'chapter_update_notification' => 'Розділ успішно оновлено',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'видалив розділ',
-    'chapter_delete_notification' => 'Розділ успішно видалено',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'перемістив розділ',
 
     // Books
     'book_create'                 => 'створив книгу',
-    'book_create_notification'    => 'Книгу успішно створено',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'оновив книгу',
-    'book_update_notification'    => 'Книгу успішно оновлено',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'видалив книгу',
-    'book_delete_notification'    => 'Книгу успішно видалено',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'sorted книгу',
-    'book_sort_notification'      => 'Книгу успішно відновлено',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'створив книжкову полицю',
-    'bookshelf_create_notification'    => 'Книжкову полицю успішно створено',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'оновив книжкову полицю',
-    'bookshelf_update_notification'    => 'Книжкову полицю успішно оновлено',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'видалив книжкову полицю',
-    'bookshelf_delete_notification'    => 'Книжкову полицю успішно видалено',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":ім\'я" було додане до ваших улюлених',
+    'favourite_remove_notification' => '":ім\'я" було видалено з ваших улюблених',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Багатофакторний метод успішно налаштований',
+    'mfa_remove_method_notification' => 'Багатофакторний метод успішно видалений',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'прокоментував',
index e11848a2092125e02ab979bc7e4de2fec4c47dc3..04449f611c1a7312bffb42679b09916ad70a3970 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Адреса електронної пошти',
     'password' => 'Пароль',
     'password_confirm' => 'Підтвердження пароля',
-    'password_hint' => 'Має бути більше ніж 7 символів',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Забули пароль?',
     'remember_me' => 'Запам\'ятати мене',
     'ldap_email_hint' => 'Введіть email для цього облікового запису.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Цей домен електронної пошти заборонений для реєстрації',
     'register_success' => 'Дякуємо за реєстрацію! Ви зареєстровані та ввійшли в систему.',
 
-
     // Password Reset
     'reset_password' => 'Скинути пароль',
     'reset_password_send_instructions' => 'Введіть адресу електронної пошти нижче, і вам буде надіслано електронне повідомлення з посиланням на зміну пароля.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Ви отримали цей електронний лист, оскільки до нас надійшов запит на скидання пароля для вашого облікового запису.',
     'email_reset_not_requested' => 'Якщо ви не надсилали запит на скидання пароля, подальші дії не потрібні.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Підтвердьте свою електронну пошту на :appName',
     'email_confirm_greeting' => 'Дякуємо, що приєдналися до :appName!',
     'email_confirm_text' => 'Будь ласка, підтвердьте свою адресу електронної пошти, натиснувши кнопку нижче:',
     'email_confirm_action' => 'Підтвердити Email',
     'email_confirm_send_error' => 'Необхідно підтвердження електронною поштою, але система не змогла надіслати електронний лист. Зверніться до адміністратора, щоб правильно налаштувати електронну пошту.',
-    'email_confirm_success' => 'Ваш електронну адресу підтверджено!',
+    'email_confirm_success' => 'Ваша адреса електронної пошти була підтверджена! Тепер ви можете увійти в систему, використовуючи цю адресу електронної пошти.',
     'email_confirm_resent' => 'Лист з підтвердженням надіслано, перевірте свою пошту.',
 
     'email_not_confirmed' => 'Адресу електронної скриньки не підтверджено',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Ласкаво просимо до :appName!',
     'user_invite_page_text' => 'Для завершення процесу створення облікового запису та отримання доступу вам потрібно задати пароль, який буде використовуватися для входу в :appName в майбутньому.',
     'user_invite_page_confirm_button' => 'Підтвердити пароль',
-    'user_invite_success' => 'Встановлено пароль, тепер у вас є доступ до :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Пароль встановлено, ви повинні увійти в систему, використовуючи свій встановлений пароль для доступу :appNam!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Налаштувати двофакторну автентифікацію',
+    'mfa_setup_desc' => 'Двофакторна аутентифікація додає ще один рівень безпеки для вашого облікового запису.',
+    'mfa_setup_configured' => 'Вже налаштовано',
+    'mfa_setup_reconfigure' => 'Переналаштувати',
+    'mfa_setup_remove_confirmation' => 'Ви впевнені, що хочете видалити цей метод багатофакторної автентифікації?',
+    'mfa_setup_action' => 'Встановлення',
+    'mfa_backup_codes_usage_limit_warning' => 'Залишилося менше 5 резервних кодів, Будь ласка, створіть та збережіть новий набір до того, як у вас не вистачає кодів, щоб запобігти блокуванню вашої обліковки.',
+    'mfa_option_totp_title' => 'Мобільний додаток',
+    'mfa_option_totp_desc' => 'Для використання багатофакторної автентифікації вам потрібен мобільний додаток, який підтримує TOTP такі як Google Authenticator, Authy або Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Резервні коди',
+    'mfa_option_backup_codes_desc' => 'Безпечно зберігайте набір резервних кодів з використанням одноразового використання, які можна увійти, щоб підтвердити свою особу.',
+    'mfa_gen_confirm_and_enable' => 'Підтвердити та увімкнути',
+    'mfa_gen_backup_codes_title' => 'Налаштування резервних кодів',
+    'mfa_gen_backup_codes_desc' => 'Зберігайте список кодів в безпечному місці. Для доступу до системи ви зможете використовувати один з кодів як другий механізм аутентифікації.',
+    'mfa_gen_backup_codes_download' => 'Завантажити коди',
+    'mfa_gen_backup_codes_usage_warning' => 'Кожний код можна використати лише один раз',
+    'mfa_gen_totp_title' => 'Налаштування мобільного додатка',
+    'mfa_gen_totp_desc' => 'Для використання багатофакторної автентифікації вам потрібен мобільний додаток, який підтримує TOTP такі як Google Authenticator, Authy або Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Проскануйте QR-код внизу за допомогою бажаного додатку для аутентифікації, щоб розпочати.',
+    'mfa_gen_totp_verify_setup' => 'Перевірка налаштувань',
+    'mfa_gen_totp_verify_setup_desc' => 'Переконайтеся, що все працює, ввівши код, згенерований у вашому додатку аутентифікації, в полі вводу нижче:',
+    'mfa_gen_totp_provide_code_here' => 'Вкажіть код вашої програми тут',
+    'mfa_verify_access' => 'Підтвердити доступ',
+    'mfa_verify_access_desc' => 'Ваш обліковий запис користувача вимагає підтвердження за допомогою додаткового рівня перевірки, перш ніж отримати доступ. Перевірте, використовуючи один з ваших налаштованих способів для продовження.',
+    'mfa_verify_no_methods' => 'Немає налаштованих методів',
+    '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' => 'Налаштовано багатофакторний метод аутентифікації. Будь ласка, зараз увійдіть в систему знову, використовуючи налаштований метод.',
+];
index 1920f125a79bf64cec7fbf4107133975daaf4e13..11000ea7be550beeb0a09fff5f3b1152d28c9e62 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Роль',
     'cover_image' => 'Обкладинка',
     'cover_image_description' => 'Це зображення має бути приблизно 440x250px.',
-    
+
     // Actions
     'actions' => 'Дії',
     'view' => 'Подивитись',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Скинути',
     'remove' => 'Видалити',
     'add' => 'Додати',
+    'configure' => 'Налаштувати',
     'fullscreen' => 'На весь екран',
+    'favourite' => 'Улюблене',
+    'unfavourite' => 'Прибрати з обраного',
+    'next' => 'Уперед',
+    'previous' => 'Назад',
+    'filter_active' => 'Активний фільтр:',
+    'filter_clear' => 'Очистити фільтр',
 
     // Sort Options
     'sort_options' => 'Параметри сортування',
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Немає активності для показу',
     'no_items' => 'Немає доступних елементів',
     'back_to_top' => 'Повернутися до початку',
+    'skip_to_main_content' => 'Перейти до змісту',
     'toggle_details' => 'Подробиці',
     'toggle_thumbnails' => 'Мініатюри',
     'details' => 'Деталі',
@@ -63,9 +71,14 @@ return [
     'list_view' => 'Вигляд Списком',
     'default' => 'За замовчуванням',
     'breadcrumb' => 'Навігація',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => 'Розгорнути меню заголовка',
     'profile_menu' => 'Меню профілю',
     'view_profile' => 'Переглянути профіль',
     'edit_profile' => 'Редагувати профіль',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => 'Інфо',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => 'Вкладка: показувати додаткову інформацію',
     'tab_content' => 'Вміст',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => 'Вкладка: Показати основний вміст',
 
     // Email Content
     'email_action_help' => 'Якщо у вас виникають проблеми при натисканні кнопки ":actionText", скопіюйте та вставте URL у свій веб-браузер:',
index 86f044102ac39f884f6877ea4c2c044148b2f4af..7496a62cab0e7ebca7d0786fd45434b2074f4e33 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => 'Зображення',
     'my_recent_drafts' => 'Мої останні чернетки',
     'my_recently_viewed' => 'Мої недавні перегляди',
+    'my_most_viewed_favourites' => 'Мої найпопулярніші улюблені',
+    'my_favourites' => 'Моє обране',
     'no_pages_viewed' => 'Ви не переглядали жодної сторінки',
     'no_pages_recently_created' => 'Не було створено жодної сторінки',
     'no_pages_recently_updated' => 'Немає недавно оновлених сторінок',
@@ -34,6 +36,7 @@ return [
     'export_html' => 'Вбудований веб-файл',
     'export_pdf' => 'PDF файл',
     'export_text' => 'Текстовий файл',
+    'export_md' => 'Файл розмітки',
 
     // Permissions and restrictions
     'permissions' => 'Дозволи',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Дозволи на книжкову полицю',
     'shelves_permissions_updated' => 'Дозволи на книжкову полицю оновлено',
     'shelves_permissions_active' => 'Діючі дозволи на книжкову полицю',
+    'shelves_permissions_cascade_warning' => 'Дозволи на поличках не потрапляють автоматично каскадом до книг. Це тому, що книга може існувати на кілька полиць. Дозволи, однак можна скопіювати до дочірніх книг, використовуючи нижченаведену опцію.',
     'shelves_copy_permissions_to_books' => 'Копіювати дозволи на книги',
     'shelves_copy_permissions' => 'Копіювати дозволи',
     'shelves_copy_permissions_explain' => 'Це застосовує поточні налаштування дозволів цієї книжкової полиці до всіх книг, що містяться всередині. Перш ніж активувати, переконайтесь що будь-які зміни дозволів цієї книжкової полиці були збережені.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Розділи в кінці',
     'books_sort_show_other' => 'Показати інші книги',
     'books_sort_save' => 'Зберегти нове замовлення',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Розділ',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Перемістити розділ',
     'chapters_move_named' => 'Перемістити розділ :chapterName',
     'chapter_move_success' => 'Розділ переміщено до :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Дозволи розділу',
     'chapters_empty' => 'У цьому розділі немає сторінок.',
     'chapters_permissions_active' => 'Діючі дозволи на розділ',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Нова сторінка',
     'pages_editing_draft_notification' => 'Ви наразі редагуєте чернетку, що була збережена останньою :timeDiff.',
     'pages_draft_edited_notification' => 'З того часу ця сторінка була оновлена. Рекомендуємо відмовитися від цього проекту.',
+    'pages_draft_page_changed_since_creation' => 'Ця сторінка була оновлена, оскільки була створена ця чернетка. Рекомендується відхилити цей проект або перейматися тим, що ви не перезапишете будь-які зміни в сторінках.',
     'pages_draft_edit_active' => [
         'start_a' => ':count користувачі(в) почали редагувати цю сторінку',
         'start_b' => ':userName розпочав редагування цієї сторінки',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Додайте кілька тегів, щоб краще класифікувати ваш вміст. \n Ви можете присвоїти значення тегу для більш глибокої організації.",
     'tags_add' => 'Додати ще один тег',
     'tags_remove' => 'Видалити цей тег',
+    'tags_usages' => 'Усього тегів використано',
+    'tags_assigned_pages' => 'Призначено до сторінок',
+    'tags_assigned_chapters' => 'Призначені до груп',
+    'tags_assigned_books' => 'Призначено до книг',
+    'tags_assigned_shelves' => 'Призначені до полиць',
+    'tags_x_unique_values' => ':count унікальних значень',
+    'tags_all_values' => 'Всі значення',
+    'tags_view_tags' => 'Перегляд міток',
+    'tags_view_existing_tags' => 'Перегляд існуючих тегів',
+    'tags_list_empty_hint' => 'Теги можуть бути призначені через бічну панель редактора сторінки, або під час редагування деталей книги, глави чи полиці.',
     'attachments' => 'Вкладення',
     'attachments_explain' => 'Завантажте файли, або додайте посилання, які відображатимуться на вашій сторінці. Їх буде видно на бічній панелі сторінки.',
     'attachments_explain_instant_save' => 'Зміни тут зберігаються миттєво.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Ви впевнені, що хочете видалити цю версію?',
     'revision_restore_confirm' => 'Дійсно відновити цю версію? Вміст поточної сторінки буде замінено.',
     'revision_delete_success' => 'Версія видалена',
-    'revision_cannot_delete_latest' => 'Неможливо видалити останню версію.'
+    'revision_cannot_delete_latest' => 'Неможливо видалити останню версію.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index eb20bae74bf57e921caf9cbce25e2448d228e049..ee99e3954ed8b4c81930408f509231848dbcd550 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Не вдалося знайти електронну адресу для цього користувача у даних, наданих зовнішньою системою аутентифікації',
     'saml_invalid_response_id' => 'Запит із зовнішньої системи аутентифікації не розпізнається процесом, розпочатим цим додатком. Повернення назад після входу могла спричинити цю проблему.',
     'saml_fail_authed' => 'Вхід із використанням «:system» не вдався, система не здійснила успішну авторизацію',
+    'oidc_already_logged_in' => 'Вже ввійшли в систему',
+    'oidc_user_not_registered' => 'Користувач :name не зареєстровано і автоматична реєстрація відключена',
+    'oidc_no_email_address' => 'Не вдалося знайти адресу електронної пошти для цього користувача у даних, наданих зовнішньою системою автентифікації',
+    'oidc_fail_authed' => 'Увійти за допомогою :system не вдалося, система не надала успішної авторизації',
     'social_no_action_defined' => 'Жодних дій не визначено',
     'social_login_bad_response' => "Помилка, отримана під час входу з :socialAccount помилка : \n:error",
     'social_account_in_use' => 'Цей :socialAccount обліковий запис вже використовується, спробуйте ввійти з параметрами :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Сторінку не знайдено',
     'sorry_page_not_found' => 'Вибачте, сторінку, яку ви шукали, не знайдено.',
     'sorry_page_not_found_permission_warning' => 'Якщо ви очікували що ця сторінки існує – можливо у вас немає дозволу на її перегляд.',
+    'image_not_found' => 'Зображення не знайдено',
+    'image_not_found_subtitle' => 'Вибачте, файл зображення, що ви шукали, не знайдено.',
+    'image_not_found_details' => 'Якщо ви очікували існування цього зображення, його, можливо, було видалено.',
     'return_home' => 'Повернутися на головну',
     'error_occurred' => 'Виникла помилка',
     'app_down' => ':appName зараз недоступний',
index 12c2561325e5aa1af7016c88916d6f2328b0b0c8..59ea4ea7c7f1c90a8cfb5ca6fd3f1021a61dd07c 100644 (file)
@@ -17,7 +17,7 @@ return [
     'app_name' => 'Назва програми',
     'app_name_desc' => 'Ця назва показується у заголовку та в усіх листах.',
     'app_name_header' => 'Показати назву програми в заголовку',
-    'app_public_access' => 'Публічнй доступ',
+    'app_public_access' => 'Ð\9fÑ\83блÑ\96Ñ\87ний Ð´Ð¾Ñ\81Ñ\82Ñ\83п',
     'app_public_access_desc' => 'Увімкнення цієї опції дозволить відвідувачам, які не увійшли в систему, отримати доступ до вмісту у вашому екземплярі BookStack.',
     'app_public_access_desc_guest' => 'Доступ для публічних відвідувачів можна контролювати через користувача "Гість".',
     'app_public_access_toggle' => 'Дозволити публічний доступ',
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Обслуговування',
     'maint_image_cleanup' => 'Очищення зображень',
-    'maint_image_cleanup_desc' => "Сканує вміст сторінки та версій, щоб перевірити, які зображення та малюнки в даний час використовуються, а також які зображення зайві. Переконайтеся, що ви створили повну резервну копію бази даних та зображення, перш ніж запускати це.",
+    'maint_image_cleanup_desc' => 'Сканує вміст сторінки та версій, щоб перевірити, які зображення та малюнки в даний час використовуються, а також які зображення зайві. Переконайтеся, що ви створили повну резервну копію бази даних та зображення, перш ніж запускати це.',
     'maint_delete_images_only_in_revisions' => 'Також видалити зображення, що існують лише в старих версіях сторінки',
     'maint_image_cleanup_run' => 'Запустити очищення',
     'maint_image_cleanup_warning' => ':count потенційно невикористаних зображень було знайдено. Ви впевнені, що хочете видалити ці зображення?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Кошик',
     'recycle_bin_desc' => 'Тут ви можете відновити видалені елементи, або назавжди видалити їх із системи. Цей список нефільтрований, на відміну від подібних списків активності в системі, де застосовуються фільтри дозволів.',
     'recycle_bin_deleted_item' => 'Виадлений елемент',
+    'recycle_bin_deleted_parent' => 'Батьківський',
     'recycle_bin_deleted_by' => 'Ким видалено',
     'recycle_bin_deleted_at' => 'Час видалення',
     'recycle_bin_permanently_delete' => 'Видалити остаточно',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Елементи для відновлення',
     'recycle_bin_restore_confirm' => 'Ця дія відновить видалений елемент у початкове місце, включаючи всі дочірні елементи. Якщо вихідне розташування відтоді було видалено, і знаходиться у кошику, батьківський елемент також потрібно буде відновити.',
     'recycle_bin_restore_deleted_parent' => 'Батьківський елемент цього об\'єкта також був видалений. Вони залишатимуться видаленими, доки батьківський елемент також не буде відновлений.',
+    'recycle_bin_restore_parent' => 'Відновити батьківську',
     'recycle_bin_destroy_notification' => 'Видалено :count елементів із кошика.',
     'recycle_bin_restore_notification' => 'Відновлено :count елементів із кошика.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Користувач',
     'audit_table_event' => 'Подія',
     'audit_table_related' => 'Пов’язаний елемент',
+    'audit_table_ip' => 'IP-адреса',
     'audit_table_date' => 'Дата активності',
     'audit_date_from' => 'Діапазон дат від',
     'audit_date_to' => 'Діапазон дат до',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Деталі ролі',
     'role_name' => 'Назва ролі',
     'role_desc' => 'Короткий опис ролі',
+    'role_mfa_enforced' => 'Потрібна двофактова автентифікація',
     'role_external_auth_id' => 'Зовнішні ID автентифікації',
     'role_system' => 'Системні дозволи',
     'role_manage_users' => 'Керування користувачами',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Управління шаблонами сторінок',
     'role_access_api' => 'Доступ до системного API',
     'role_manage_settings' => 'Керування налаштуваннями програми',
+    'role_export_content' => 'Вміст експорту',
     'role_asset' => 'Дозволи',
     'roles_system_warning' => 'Майте на увазі, що доступ до будь-якого з вищезазначених трьох дозволів може дозволити користувачеві змінювати власні привілеї або привілеї інших в системі. Ролі з цими дозволами призначайте лише довіреним користувачам.',
     'role_asset_desc' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Ролі користувача',
     'users_role_desc' => 'Виберіть, до яких ролей буде призначено цього користувача. Якщо користувачеві призначено декілька ролей, дозволи з цих ролей будуть складатись і вони отримуватимуть усі можливості призначених ролей.',
     'users_password' => 'Пароль користувача',
-    'users_password_desc' => 'Встановіть пароль для входу. Він повинен містити принаймні 5 символів.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Ви можете надіслати цьому користувачеві лист із запрошенням, що дозволить йому встановити пароль власноруч, або ви можете встановити йому пароль самостійно.',
     'users_send_invite_option' => 'Надіслати листа із запрошенням користувачу',
     'users_external_auth_id' => 'Зовнішній ID автентифікації',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Створити токен',
     'users_api_tokens_expires' => 'Закінчується',
     'users_api_tokens_docs' => 'Документація API',
+    'users_mfa' => 'Багатофакторна Автентифікація',
+    'users_mfa_desc' => 'Двофакторна аутентифікація додає ще один рівень безпеки для вашого облікового запису.',
+    'users_mfa_x_methods' => ':count метод налаштовано|:count методів налаштовано',
+    'users_mfa_configure' => 'Налаштувати Методи',
 
     // API Tokens
     'user_api_token_create' => 'Створити токен API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Дійсно хочете видалити цей токен API?',
     'user_api_token_delete_success' => 'Токен API успішно видалено',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 77df1ed4ea627eea4b088b5aaad4b8e67d0af38a..25322f2d9c042d85e6c335290e1b91acd001628c 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => 'Поле :attribute має містити лише літери, цифри, дефіси та підкреслення.',
     'alpha_num'            => 'Поле :attribute має містити лише літери та цифри.',
     'array'                => 'Поле :attribute має бути масивом.',
+    'backup_codes'         => 'Наданий код є недійсним або вже використаний.',
     'before'               => 'Поле :attribute має містити дату не пізніше :date.',
     'between'              => [
         'numeric' => 'Поле :attribute має бути між :min та :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'Поле :attribute повинне містити текст.',
     'timezone'             => 'Поле :attribute повинне містити коректну часову зону.',
+    'totp'                 => 'Наданий код не є дійсним або прострочений.',
     'unique'               => 'Вказане значення поля :attribute вже існує.',
     'url'                  => 'Формат поля :attribute неправильний.',
     'uploaded'             => 'Не вдалося завантажити файл. Сервер може не приймати файли такого розміру.',
index 85a850e274bcc4bb1e9d5c868ccacac7658bc2ac..67199462f16f87a9327a78a9d540461af16974da 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => 'đã tạo trang',
-    'page_create_notification'    => 'Trang đã được tạo thành công',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => 'đã cập nhật trang',
-    'page_update_notification'    => 'Trang đã được cập nhật thành công',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => 'đã xóa trang',
-    'page_delete_notification'    => 'Trang đã được xóa thành công',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => 'đã khôi phục trang',
-    'page_restore_notification'   => 'Trang đã được khôi phục thành công',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => 'đã di chuyển trang',
 
     // Chapters
     'chapter_create'              => 'đã tạo chương',
-    'chapter_create_notification' => 'Chương đã được tạo thành công',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => 'đã cập nhật chương',
-    'chapter_update_notification' => 'Chương đã được cập nhật thành công',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => 'đã xóa chương',
-    'chapter_delete_notification' => 'Chương đã được xóa thành công',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => 'đã di chuyển chương',
 
     // Books
     'book_create'                 => 'đã tạo sách',
-    'book_create_notification'    => 'Sách đã được tạo thành công',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => 'đã cập nhật sách',
-    'book_update_notification'    => 'Sách đã được cập nhật thành công',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => 'đã xóa sách',
-    'book_delete_notification'    => 'Sách đã được xóa thành công',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => 'đã sắp xếp sách',
-    'book_sort_notification'      => 'Sách đã được sắp xếp lại thành công',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => 'đã tạo giá sách',
-    'bookshelf_create_notification'    => 'Giá sách đã được tạo thành công',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => 'cập nhật giá sách',
-    'bookshelf_update_notification'    => 'Giá sách đã tạo thành công',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => 'đã xóa giá sách',
-    'bookshelf_delete_notification'    => 'Giá sách đã được xóa thành công',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" đã được thêm vào danh sách yêu thích của bạn',
+    'favourite_remove_notification' => '":name" đã được gỡ khỏi danh sách yêu thích của bạn',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Cấu hình xác thực nhiều bước thành công',
+    'mfa_remove_method_notification' => 'Đã gỡ xác thực nhiều bước',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => 'đã bình luận về',
index 5ba2db390f6280cc791f345003109c33968a8313..63fe8b292d9e62667120e16b74ddd72f2b33ba17 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => 'Email',
     'password' => 'Mật khẩu',
     'password_confirm' => 'Xác nhận mật khẩu',
-    'password_hint' => 'Cần tối thiểu 7 kí tự',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => 'Quên Mật khẩu?',
     'remember_me' => 'Ghi nhớ đăng nhập',
     'ldap_email_hint' => 'Vui lòng điền một địa chỉ email để sử dụng tài khoản này.',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => 'Tên miền của email không có quyền truy cập tới ứng dụng này',
     'register_success' => 'Cảm ơn bạn đã đăng kí! Bạn đã được xác nhận và đăng nhập.',
 
-
     // Password Reset
     'reset_password' => 'Đặt lại mật khẩu',
     'reset_password_send_instructions' => 'Nhập email vào ô dưới đây và bạn sẽ nhận được một email với liên kết để đặt lại mật khẩu.',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => 'Bạn nhận được email này bởi vì chúng tôi nhận được một yêu cầu đặt lại mật khẩu cho tài khoản của bạn.',
     'email_reset_not_requested' => 'Nếu bạn không yêu cầu đặt lại mật khẩu, không cần có bất cứ hành động nào khác.',
 
-
     // Email Confirmation
     'email_confirm_subject' => 'Xác nhận email trên :appName',
     'email_confirm_greeting' => 'Cảm ơn bạn đã tham gia :appName!',
     'email_confirm_text' => 'Xin hãy xác nhận địa chỉa email bằng cách bấm vào nút dưới đây:',
     'email_confirm_action' => 'Xác nhận Email',
     'email_confirm_send_error' => 'Email xác nhận cần gửi nhưng hệ thống đã không thể gửi được email. Liên hệ với quản trị viên để chắc chắn email được thiết lập đúng.',
-    'email_confirm_success' => 'Email của bạn đã được xác nhận!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => 'Email xác nhận đã được gửi lại, Vui lòng kiểm tra hộp thư.',
 
     'email_not_confirmed' => 'Địa chỉ email chưa được xác nhận',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => 'Chào mừng đến với :appName!',
     'user_invite_page_text' => 'Để hoàn tất tài khoản và lấy quyền truy cập bạn cần đặt mật khẩu để sử dụng cho các lần đăng nhập sắp tới tại :appName.',
     'user_invite_page_confirm_button' => 'Xác nhận Mật khẩu',
-    'user_invite_success' => 'Mật khẩu đã được thiết lập, bạn có quyền truy cập đến :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Cài đặt xác thực nhiều bước',
+    'mfa_setup_desc' => 'Cài đặt xác thực nhiều bước như một lớp bảo mật khác cho tài khoản của bạn.',
+    'mfa_setup_configured' => 'Đã cài đặt',
+    'mfa_setup_reconfigure' => 'Cài đặt lại',
+    'mfa_setup_remove_confirmation' => 'Bạn có chắc muốn gỡ bỏ phương thức xác thực nhiều bước này?',
+    'mfa_setup_action' => 'Cài đặt',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Ứng dụng di động',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Mã dự phòng',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Xác nhận và Mở',
+    'mfa_gen_backup_codes_title' => 'Cài đặt Mã dự phòng',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Tải mã',
+    'mfa_gen_backup_codes_usage_warning' => 'Mỗi mã chỉ có thể sử dụng một lần',
+    'mfa_gen_totp_title' => 'Cài đặt ứng dụng di động',
+    'mfa_gen_totp_desc' => 'Để sử dụng xác thực nhiều bước, bạn cần một ứng dụng di động hỗ trợ TOTP ví dụ như Google Authenticator, Authy hoặc Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Quét mã QR dưới đây bằng ứng dụng xác thực mà bạn muốn để bắt đầu.',
+    'mfa_gen_totp_verify_setup' => 'Xác nhận cài đặt',
+    'mfa_gen_totp_verify_setup_desc' => 'Xác nhận rằng tất cả hoạt động bằng cách nhập vào một mã, được tạo ra bởi ứng dụng xác thực của bạn vào ô dưới đây:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Xác thực truy cập',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'Không có phương pháp nào được cấu hình',
+    'mfa_verify_no_methods_desc' => 'Tài khoản của bạn chưa đăng ký xác thực nhiều lớp. Bạn cần thiết lập ít nhất một phương pháp trước khi yêu cầu truy cập.',
+    'mfa_verify_use_totp' => 'Xác thực sử dụng mã di động',
+    'mfa_verify_use_backup_codes' => 'Xác thực sử dụng mã backup',
+    'mfa_verify_backup_code' => 'Mã dự phòng',
+    'mfa_verify_backup_code_desc' => 'Nhập một trong các mã dự phòng còn lại của bạn vào ô phía dưới:',
+    'mfa_verify_backup_code_enter_here' => 'Nhập mã xác thực của bạn tại đây',
+    'mfa_verify_totp_desc' => 'Nhập mã do ứng dụng di động của bạn tạo ra vào dưới đây:',
+    'mfa_setup_login_notification' => 'Đã cài đặt xác thực nhiều bước, bạn vui lòng đăng nhập lại sử dụng phương thức đã cài đặt.',
+];
index b48c65676ad604bdd967f5c2eeb7e21868c70ce1..58447720e20c76fe78080ffa9e046552aa67bb75 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => 'Quyền',
     'cover_image' => 'Ảnh bìa',
     'cover_image_description' => 'Ảnh nên có kích thước 440x250px.',
-    
+
     // Actions
     'actions' => 'Hành động',
     'view' => 'Xem',
@@ -39,7 +39,14 @@ return [
     'reset' => 'Thiết lập lại',
     'remove' => 'Xóa bỏ',
     'add' => 'Thêm',
+    'configure' => 'Cấu hình',
     'fullscreen' => 'Toàn màn hình',
+    'favourite' => 'Yêu thích',
+    'unfavourite' => 'Bỏ yêu thích',
+    'next' => 'Tiếp theo',
+    'previous' => 'Trước đó',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Tùy Chọn Sắp Xếp',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => 'Sắp xếp tăng dần',
     'sort_descending' => 'Sắp xếp giảm dần',
     'sort_name' => 'Tên',
-    'sort_default' => 'Default',
+    'sort_default' => 'Mặc định',
     'sort_created_at' => 'Ngày Tạo',
     'sort_updated_at' => 'Ngày cập nhật',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => 'Không có hoạt động nào',
     'no_items' => 'Không có mục nào khả dụng',
     'back_to_top' => 'Lên đầu trang',
+    'skip_to_main_content' => 'Nhảy đến nội dung chính',
     'toggle_details' => 'Bật/tắt chi tiết',
     'toggle_thumbnails' => 'Bật/tắt ảnh ảnh nhỏ',
     'details' => 'Chi tiết',
@@ -63,6 +71,11 @@ return [
     'list_view' => 'Hiển thị dạng danh sách',
     'default' => 'Mặc định',
     'breadcrumb' => 'Đường dẫn liên kết',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => 'Expand Header Menu',
@@ -84,6 +97,6 @@ return [
 
     // Footer Link Options
     // Not directly used but available for convenience to users.
-    'privacy_policy' => 'Privacy Policy',
-    'terms_of_service' => 'Terms of Service',
+    'privacy_policy' => 'Chính Sách Quyền Riêng Tư',
+    'terms_of_service' => 'Điều khoản Dịch vụ',
 ];
index e72f696488f49171af2ac5e9709ab94d9f094bbf..7dfbe52561595e075248673105f3fa692f441ba7 100644 (file)
@@ -22,11 +22,13 @@ return [
     'meta_created_name' => 'Được tạo :timeLength bởi :user',
     'meta_updated' => 'Được cập nhật :timeLength',
     'meta_updated_name' => 'Được cập nhật :timeLength bởi :user',
-    'meta_owned_name' => 'Owned by :user',
+    'meta_owned_name' => 'Được sở hữu bởi :user',
     'entity_select' => 'Chọn thực thể',
     'images' => 'Ảnh',
     'my_recent_drafts' => 'Bản nháp gần đây của tôi',
     'my_recently_viewed' => 'Xem gần đây',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'Danh sách yêu thích của tôi',
     'no_pages_viewed' => 'Bạn chưa xem bất cứ trang nào',
     'no_pages_recently_created' => 'Không có trang nào được tạo gần đây',
     'no_pages_recently_updated' => 'Không có trang nào được cập nhật gần đây',
@@ -34,13 +36,14 @@ return [
     'export_html' => 'Đang chứa tệp tin Web',
     'export_pdf' => 'Tệp PDF',
     'export_text' => 'Tệp văn bản thuần túy',
+    'export_md' => '\bTệp Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Quyền',
     'permissions_intro' => 'Một khi được bật, các quyền này sẽ được ưu tiên trên hết tất cả các quyền hạn khác.',
     'permissions_enable' => 'Bật quyền hạn tùy chỉnh',
     'permissions_save' => 'Lưu quyền hạn',
-    'permissions_owner' => 'Owner',
+    'permissions_owner' => 'Chủ sở hữu',
 
     // Search
     'search_results' => 'Kết quả Tìm kiếm',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => 'Phân quyền',
     'search_created_by_me' => 'Được tạo bởi tôi',
     'search_updated_by_me' => 'Được cập nhật bởi tôi',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => 'Của tôi',
     'search_date_options' => 'Tùy chọn ngày',
     'search_updated_before' => 'Đã được cập nhật trước đó',
     'search_updated_after' => 'Đã được cập nhật sau',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => 'Các quyền đối với kệ sách',
     'shelves_permissions_updated' => 'Các quyền với kệ sách đã được cập nhật',
     'shelves_permissions_active' => 'Đang bật các quyền hạn từ Kệ sách',
+    'shelves_permissions_cascade_warning' => 'Các quyền trên giá sách sẽ không được tự động gán cho các sách trên đó. Vì một quyển sách có thể tồn tại trên nhiều giá sách. Các quyền có thể được sao chép xuống các quyển sách sử dụng tuỳ chọn dưới đây.',
     'shelves_copy_permissions_to_books' => 'Sao chép các quyền cho sách',
     'shelves_copy_permissions' => 'Sao chép các quyền',
     'shelves_copy_permissions_explain' => 'Điều này sẽ áp dụng các cài đặt quyền của giá sách hiện tại với tất cả các cuốn sách bên trong. Trước khi kích hoạt, đảm bảo bất cứ thay đổi liên quan đến quyền của giá sách này đã được lưu.',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => 'Các Chương cuối',
     'books_sort_show_other' => 'Hiển thị các Sách khác',
     'books_sort_save' => 'Lưu thứ tự mới',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => 'Chương',
@@ -149,7 +155,7 @@ return [
     'chapters_create' => 'Tạo Chương mới',
     'chapters_delete' => 'Xóa Chương',
     'chapters_delete_named' => 'Xóa Chương :chapterName',
-    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
+    'chapters_delete_explain' => 'Hành động này sẽ xoá chương \':chapterName\'. Tất cả các trang trong chương này cũng sẽ bị xoá.',
     'chapters_delete_confirm' => 'Bạn có chắc chắn muốn xóa chương này?',
     'chapters_edit' => 'Sửa Chương',
     'chapters_edit_named' => 'Sửa chương :chapterName',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => 'Di chuyển Chương',
     'chapters_move_named' => 'Di chuyển Chương :chapterName',
     'chapter_move_success' => 'Chương được di chuyển đến :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => 'Quyền hạn Chương',
     'chapters_empty' => 'Không có trang nào hiện có trong chương này.',
     'chapters_permissions_active' => 'Đang bật các quyền hạn từ Chương',
@@ -211,7 +219,7 @@ return [
     'pages_revisions' => 'Phiên bản Trang',
     'pages_revisions_named' => 'Phiên bản Trang cho :pageName',
     'pages_revision_named' => 'Phiên bản Trang cho :pageName',
-    'pages_revision_restored_from' => 'Restored from #:id; :summary',
+    'pages_revision_restored_from' => 'Khôi phục từ #:id; :summary',
     'pages_revisions_created_by' => 'Tạo bởi',
     'pages_revisions_date' => 'Ngày của Phiên bản',
     'pages_revisions_number' => '#',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => 'Trang mới',
     'pages_editing_draft_notification' => 'Bạn hiện đang chỉnh sửa một bản nháp được lưu cách đây :timeDiff.',
     'pages_draft_edited_notification' => 'Trang này đã được cập nhật từ lúc đó. Bạn nên loại bỏ bản nháp này.',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count người dùng đang bắt đầu chỉnh sửa trang này',
         'start_b' => ':userName đang bắt đầu chỉnh sửa trang này',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "Thêm vài thẻ để phân loại nội dung của bạn tốt hơn. \n Bạn có thể đặt giá trị cho thẻ để quản lí kĩ càng hơn.",
     'tags_add' => 'Thêm thẻ khác',
     'tags_remove' => 'Xóa thẻ này',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => 'Các Đính kèm',
     'attachments_explain' => 'Cập nhật một số tập tin và đính một số liên kết để hiển thị trên trang của bạn. Chúng được hiện trong sidebar của trang.',
     'attachments_explain_instant_save' => 'Các thay đổi ở đây sẽ được lưu ngay lập tức.',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => 'Bạn có chắc bạn muốn xóa phiên bản này?',
     'revision_restore_confirm' => 'Bạn có chắc bạn muốn khôi phục phiên bản này? Nội dung trang hiện tại sẽ được thay thế.',
     'revision_delete_success' => 'Phiên bản đã được xóa',
-    'revision_cannot_delete_latest' => 'Không thể xóa phiên bản mới nhất.'
+    'revision_cannot_delete_latest' => 'Không thể xóa phiên bản mới nhất.',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index 6410fb97092748f0b91064b4b91d49d5d270e714..ff1abbf8e545244edd89315f92d1facb9e0cfc6d 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => 'Không tìm thấy địa chỉ email cho người dùng này trong dữ liệu được cung cấp bới hệ thống xác thực ngoài',
     'saml_invalid_response_id' => 'Yêu cầu từ hệ thống xác thực bên ngoài không được nhận diện bởi quy trình chạy cho ứng dụng này. Điều hướng trở lại sau khi đăng nhập có thể đã gây ra vấn đề này.',
     'saml_fail_authed' => 'Đăng nhập sử dụng :system thất bại, hệ thống không cung cấp được sự xác thực thành công',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => 'Không có hành động được xác định',
     'social_login_bad_response' => "Xảy ra lỗi trong lúc đăng nhập :socialAccount: \n:error",
     'social_account_in_use' => 'Tài khoản :socialAccount này đang được sử dụng, Vui lòng thử đăng nhập bằng tùy chọn :socialAccount.',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => 'Không Tìm Thấy Trang',
     'sorry_page_not_found' => 'Xin lỗi, Không tìm thấy trang bạn đang tìm kiếm.',
     'sorry_page_not_found_permission_warning' => 'Nếu trang bạn tìm kiếm tồn tại, có thể bạn đang không có quyền truy cập.',
+    'image_not_found' => 'Không tìm thấy Ảnh',
+    'image_not_found_subtitle' => 'Rất tiếc, không thể tìm thấy Ảnh bạn đang tìm kiếm.',
+    'image_not_found_details' => 'Nếu bạn hi vọng ảnh này tồn tại, rất có thể nó đã bị xóa.',
     'return_home' => 'Quay lại trang chủ',
     'error_occurred' => 'Đã xảy ra lỗi',
     'app_down' => ':appName hiện đang ngoại tuyến',
index 6f282a568c4b28ffbb865b3862ab99b3c17af9dd..b1f9e9c1c7d7006cd160deee9701a1e4a25e16d2 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => 'Bảo trì',
     'maint_image_cleanup' => 'Dọn dẹp ảnh',
-    'maint_image_cleanup_desc' => "Quét nội dung trang và phiên bản để kiểm tra xem các ảnh và hình vẽ nào đang được sử dụng và ảnh nào dư thừa. Đảm bảo rằng bạn đã tạo bản sao lưu toàn dữ liệu và ảnh trước khi chạy chức năng này.",
+    'maint_image_cleanup_desc' => 'Quét nội dung trang và phiên bản để kiểm tra xem các ảnh và hình vẽ nào đang được sử dụng và ảnh nào dư thừa. Đảm bảo rằng bạn đã tạo bản sao lưu toàn dữ liệu và ảnh trước khi chạy chức năng này.',
     'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
     'maint_image_cleanup_run' => 'Chạy Dọn dẹp',
     'maint_image_cleanup_warning' => 'Đã tìm thấy :count ảnh có thể không được sử dụng. Bạn muốn chắc rằng muốn xóa các ảnh này?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => 'Thùng Rác',
     'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
     'recycle_bin_deleted_item' => 'Mục Đã Xóa',
+    'recycle_bin_deleted_parent' => 'Parent',
     'recycle_bin_deleted_by' => 'Xóa Bởi',
     'recycle_bin_deleted_at' => 'Thời điểm Xóa',
     'recycle_bin_permanently_delete' => 'Xóa Vĩnh viễn',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => 'Items to be Restored',
     'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
     'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+    'recycle_bin_restore_parent' => 'Restore Parent',
     'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
     'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => 'Người dùng',
     'audit_table_event' => 'Sự kiện',
     'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => 'Ngày hoạt động',
     'audit_date_from' => 'Ngày từ khoảng',
     'audit_date_to' => 'Ngày đến khoảng',
@@ -136,6 +139,7 @@ return [
     'role_details' => 'Thông tin chi tiết Quyền',
     'role_name' => 'Tên quyền',
     'role_desc' => 'Thông tin vắn tắt của Quyền',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Mã của xác thực ngoài',
     'role_system' => 'Quyền Hệ thống',
     'role_manage_users' => 'Quản lý người dùng',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => 'Quản lý các mẫu trang',
     'role_access_api' => 'Truy cập đến API hệ thống',
     'role_manage_settings' => 'Quản lý cài đặt của ứng dụng',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Quyền tài sản (asset)',
     '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' => '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 se ghi đè các quyền này.',
@@ -169,7 +174,7 @@ return [
     'users_role' => 'Quyền người dùng',
     'users_role_desc' => 'Chọn quyền mà người dùng sẽ được gán. Nếu người dùng được gán nhiều quyền, các quyền hạn sẽ ghi đè lên nhau và họ sẽ nhận được tất cả các quyền hạn từ quyền được gán.',
     'users_password' => 'Mật khẩu người dùng',
-    'users_password_desc' => 'Đặt mật khẩu dùng để đăng nhập ứng dụng. Nó phải có độ dài tối thiểu 6 ký tự.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => 'Bạn có thể chọn để gửi cho người dùng này một email mời, giúp họ có thể tự đặt mật khẩu cho chính họ. Nếu không bạn có thể đặt mật khẩu cho họ.',
     'users_send_invite_option' => 'Gửi email mời người dùng',
     'users_external_auth_id' => 'Mã của xác thực ngoài',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => 'Tạo Token',
     'users_api_tokens_expires' => 'Hết hạn',
     'users_api_tokens_docs' => 'Tài liệu API',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => 'Tạo Token API',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => 'Bạn có chắc rằng muốn xóa token API này?',
     'user_api_token_delete_success' => 'Token API đã được xóa thành công',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index bcfb178fb5e2edb9d4bbde9c69e4cbb878534089..7e237cf9e7c0f6dbcb4b1e0887f8fdb4fbe15be6 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute chỉ được chứa chữ cái, chữ số, gạch nối và gạch dưới.',
     'alpha_num'            => ':attribute chỉ được chứa chữ cái hoặc chữ số.',
     'array'                => ':attribute phải là một mảng.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute phải là một ngày trước :date.',
     'between'              => [
         'numeric' => ':attribute phải nằm trong khoảng :min đến :max.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute phải là một chuỗi.',
     'timezone'             => ':attribute phải là một khu vực hợp lệ.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute đã có người sử dụng.',
     'url'                  => 'Định dạng của :attribute không hợp lệ.',
     '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.',
index 717c7dfdf98a7bbd4b292201a4274bf49cd746ea..834ed13326ae16a2f98a097fad76c0ad98588125 100644 (file)
@@ -7,33 +7,33 @@ return [
 
     // Pages
     'page_create'                 => '创建了页面',
-    'page_create_notification'    => '页面已创建成功',
+    'page_create_notification'    => '页面已成功创建',
     'page_update'                 => '更新了页面',
-    'page_update_notification'    => '页é\9d¢å·²æ\9b´æ\96°æ\88\90å\8a\9f',
+    'page_update_notification'    => '页é\9d¢å·²æ\88\90å\8a\9fæ\9b´æ\96°',
     'page_delete'                 => '删除了页面',
-    'page_delete_notification'    => '页面已删除成功',
+    'page_delete_notification'    => '页面已成功删除',
     'page_restore'                => '恢复了页面',
-    'page_restore_notification'   => '页é\9d¢å·²æ\81¢å¤\8dæ\88\90å\8a\9f',
+    'page_restore_notification'   => '页é\9d¢å·²æ\88\90å\8a\9fæ\81¢å¤\8d',
     'page_move'                   => '移动了页面',
 
     // Chapters
     'chapter_create'              => '创建了章节',
-    'chapter_create_notification' => '章节已创建成功',
+    'chapter_create_notification' => '章节已成功创建',
     'chapter_update'              => '更新了章节',
-    'chapter_update_notification' => '章节已创建成功',
+    'chapter_update_notification' => '章节已成功更新',
     'chapter_delete'              => '删除了章节',
-    'chapter_delete_notification' => '章节已删除成功',
+    'chapter_delete_notification' => '章节已成功删除',
     'chapter_move'                => '移动了章节',
 
     // Books
     'book_create'                 => '创建了图书',
-    'book_create_notification'    => '图书已创建成功',
+    'book_create_notification'    => '图书已成功创建',
     'book_update'                 => '更新了图书',
-    'book_update_notification'    => 'å\9b¾ä¹¦å·²æ\9b´æ\96°æ\88\90å\8a\9f',
+    'book_update_notification'    => 'å\9b¾ä¹¦å·²æ\88\90å\8a\9fæ\9b´æ\96°',
     'book_delete'                 => '删除了图书',
-    'book_delete_notification'    => '图书已删除成功',
+    'book_delete_notification'    => '图书已成功删除',
     'book_sort'                   => '排序了图书',
-    'book_sort_notification'      => '图书已重新排序成功',
+    'book_sort_notification'      => '图书已成功重新排序',
 
     // Bookshelves
     'bookshelf_create'            => '创建了书架',
@@ -43,6 +43,22 @@ return [
     'bookshelf_delete'                 => '删除了书架',
     'bookshelf_delete_notification'    => '书架已成功删除',
 
+    // Favourites
+    'favourite_add_notification' => '":name" 已添加到您的收藏',
+    'favourite_remove_notification' => '":name" 已从您的收藏中删除',
+
+    // MFA
+    'mfa_setup_method_notification' => '多重身份认证设置成功',
+    'mfa_remove_method_notification' => '多重身份认证已成功移除',
+
+    // Webhooks
+    'webhook_create' => '创建了 webhook',
+    'webhook_create_notification' => 'Webhook 已成功创建',
+    'webhook_update' => '更新了 webhook',
+    'webhook_update_notification' => 'Webhook 已成功更新',
+    'webhook_delete' => '删除了 webhook',
+    'webhook_delete_notification' => 'Webhook 已成功删除',
+
     // Other
     'commented_on'                => '评论',
     'permissions_update'          => '权限已更新',
index dcceb3c6a58ed75876e9a003d1a75256d3653916..459b5f02f0e092103b82b11895e66ffe432e2d86 100644 (file)
@@ -13,7 +13,7 @@ return [
     'sign_up' => '注册',
     'log_in' => '登录',
     'log_in_with' => '以:socialDriver登录',
-    'sign_up_with' => '注册:socialDriver',
+    'sign_up_with' => '通过 :socialDriver 账号登录',
     'logout' => '注销',
 
     'name' => '名称',
@@ -21,16 +21,16 @@ return [
     'email' => 'Email地址',
     'password' => '密码',
     'password_confirm' => '确认密码',
-    'password_hint' => 'å¿\85é¡»è\85è¿\877个字符',
+    'password_hint' => 'å¿\85é¡»è\87³å°\91æ\9c\89 8 个字符',
     'forgot_password' => '忘记密码?',
     'remember_me' => '记住我',
     'ldap_email_hint' => '请输入用于此帐户的电子邮件。',
     'create_account' => '创建账户',
-    'already_have_account' => '您已经有账号?',
-    'dont_have_account' => 'æ\82¨è¿\98没注å\86\8c?',
+    'already_have_account' => '已经有账号了?',
+    'dont_have_account' => 'æ\82¨è¿\98没æ\9c\89è´¦å\8f·å\90\97?',
     'social_login' => 'SNS登录',
-    'social_registration' => 'SNS注册',
-    'social_registration_text' => '其他服务注册/登录。',
+    'social_registration' => '使用社交网站账号注册',
+    'social_registration_text' => '使用其他服务注册并登录。',
 
     'register_thanks' => '注册完成!',
     'register_confirm' => '请点击查收您的Email,并点击确认。',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => '该Email域名无权访问此应用程序',
     'register_success' => '感谢您注册:appName,您现在已经登录。',
 
-
     // Password Reset
     'reset_password' => '重置密码',
     'reset_password_send_instructions' => '在下面输入您的Email地址,您将收到一封带有密码重置链接的邮件。',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => '您收到此电子邮件是因为我们收到了您的帐户的密码重置请求。',
     'email_reset_not_requested' => '如果您没有要求重置密码,则不需要采取进一步的操作。',
 
-
     // Email Confirmation
     'email_confirm_subject' => '确认您在:appName的Email地址',
     'email_confirm_greeting' => '感谢您加入:appName!',
     'email_confirm_text' => '请点击下面的按钮确认您的Email地址:',
     'email_confirm_action' => '确认Email',
     'email_confirm_send_error' => '需要Email验证,但系统无法发送电子邮件,请联系网站管理员。',
-    'email_confirm_success' => '您的Email地址已成功验证!',
+    'email_confirm_success' => '您已成功验证电子邮件地址!您现在可以使用此电子邮件地址登录。',
     'email_confirm_resent' => '验证邮件已重新发送,请检查收件箱。',
 
     'email_not_confirmed' => 'Email地址未验证',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => '欢迎来到 :appName!',
     'user_invite_page_text' => '要完成您的帐户并获得访问权限,您需要设置一个密码,该密码将在以后访问时用于登录 :appName。',
     'user_invite_page_confirm_button' => '确认密码',
-    'user_invite_success' => '已设置密码,您现在可以访问 :appName!'
-];
\ No newline at end of file
+    'user_invite_success_login' => '密码已设置,您现在可以使用您设置的密码登录 :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => '设置多重身份认证',
+    'mfa_setup_desc' => '设置多重身份认证能增加您账户的安全性。',
+    'mfa_setup_configured' => '已经设置过了',
+    'mfa_setup_reconfigure' => '重新配置',
+    'mfa_setup_remove_confirmation' => '您确定想要移除多重身份认证吗?',
+    'mfa_setup_action' => '设置',
+    'mfa_backup_codes_usage_limit_warning' => '您剩余的备用认证码少于 5 个,请在用完认证码之前生成并保存新的认证码,以防止您的帐户被锁定。',
+    'mfa_option_totp_title' => '移动设备 App',
+    'mfa_option_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
+    'mfa_option_backup_codes_title' => '备用认证码',
+    'mfa_option_backup_codes_desc' => '请安全地保存这些一次性使用的备用认证码,您可以输入这些认证码来验证您的身份。',
+    'mfa_gen_confirm_and_enable' => '确认并启用',
+    'mfa_gen_backup_codes_title' => '备用认证码设置',
+    'mfa_gen_backup_codes_desc' => '将下面的认证码存放在一个安全的地方。访问系统时,您可以使用其中的一个验证码进行二次认证。',
+    'mfa_gen_backup_codes_download' => '下载认证码',
+    'mfa_gen_backup_codes_usage_warning' => '每个认证码只能使用一次',
+    'mfa_gen_totp_title' => '移动设备 App',
+    'mfa_gen_totp_desc' => '要使用多重身份认证功能,您需要一个支持 TOTP(基于时间的一次性密码算法) 的移动设备 App,如谷歌身份验证器(Google Authenticator)、Authy 或微软身份验证器(Microsoft Authenticator)。',
+    'mfa_gen_totp_scan' => '要开始操作,请使用您的身份验证 App 扫描下面的二维码。',
+    'mfa_gen_totp_verify_setup' => '验证设置',
+    'mfa_gen_totp_verify_setup_desc' => '请在下面的框中输入您在身份验证 App 中生成的认证码来验证一切是否正常:',
+    'mfa_gen_totp_provide_code_here' => '在此输入您的 App 生成的认证码',
+    'mfa_verify_access' => '认证访问',
+    'mfa_verify_access_desc' => '您的账户要求您在访问前通过额外的验证确认您的身份。使用您设置的认证方法认证以继续。',
+    'mfa_verify_no_methods' => '没有设置认证方法',
+    'mfa_verify_no_methods_desc' => '您的账户没有设置多重身份认证。您需要至少设置一种才能访问。',
+    'mfa_verify_use_totp' => '使用移动设备 App 进行认证',
+    'mfa_verify_use_backup_codes' => '使用备用认证码进行认证',
+    'mfa_verify_backup_code' => '备用认证码',
+    'mfa_verify_backup_code_desc' => '在下面输入您的其中一个备用认证码:',
+    'mfa_verify_backup_code_enter_here' => '在这里输入备用认证码',
+    'mfa_verify_totp_desc' => '在下面输入您的移动 App 生成的认证码:',
+    'mfa_setup_login_notification' => '多重身份认证已设置,请使用新配置的方法重新登录。',
+];
index 72f0bd44ac9434cd0e644bae26c49511687abe8c..36fc63ca20aa16c4c3ba0336512a6cecdf316c4b 100644 (file)
@@ -19,8 +19,8 @@ return [
     'description' => '概要',
     'role' => '角色',
     'cover_image' => '封面图片',
-    'cover_image_description' => '该图像大小需要为440x250px。',
-    
+    'cover_image_description' => '此图像大小应约为 440x250 像素。',
+
     // Actions
     'actions' => '操作',
     'view' => '浏览',
@@ -39,7 +39,14 @@ return [
     'reset' => '重置',
     'remove' => '删除',
     'add' => '添加',
+    'configure' => '配置',
     'fullscreen' => '全屏',
+    'favourite' => '收藏',
+    'unfavourite' => '取消收藏',
+    'next' => '下一页',
+    'previous' => '上一页',
+    'filter_active' => '标签过滤器:',
+    'filter_clear' => '清除过滤器',
 
     // Sort Options
     'sort_options' => '排序选项',
@@ -56,6 +63,7 @@ return [
     'no_activity' => '没有活动要显示',
     'no_items' => '没有可用的项目',
     'back_to_top' => '回到顶部',
+    'skip_to_main_content' => '跳转到主要内容',
     'toggle_details' => '显示/隐藏详细信息',
     'toggle_thumbnails' => '显示/隐藏缩略图',
     'details' => '详细信息',
@@ -63,12 +71,17 @@ return [
     'list_view' => '列表视图',
     'default' => '默认',
     'breadcrumb' => '面包屑导航',
+    'status' => '状态',
+    'status_active' => '已激活',
+    'status_inactive' => '未激活',
+    'never' => '从未',
+    'none' => 'None',
 
     // Header
     'header_menu_expand' => '展开标头菜单',
     'profile_menu' => '个人资料',
-    'view_profile' => '查看资料',
-    'edit_profile' => '编辑资料',
+    'view_profile' => '查看个人资料',
+    'edit_profile' => '编辑个人资料',
     'dark_mode' => '夜间模式',
     'light_mode' => '日间模式',
 
index 09564a70a09a36862fd8673d30856ddea0d06540..e6ff95a67d23620e6750785f28c8ae7687df3b4b 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => '图片',
     'my_recent_drafts' => '我最近的草稿',
     'my_recently_viewed' => '我最近看过',
+    'my_most_viewed_favourites' => '我浏览最多的收藏',
+    'my_favourites' => '我的收藏',
     'no_pages_viewed' => '您尚未查看任何页面',
     'no_pages_recently_created' => '最近没有页面被创建',
     'no_pages_recently_updated' => '最近没有页面被更新',
@@ -34,6 +36,7 @@ return [
     'export_html' => '网页文件',
     'export_pdf' => 'PDF文件',
     'export_text' => '纯文本文件',
+    'export_md' => 'Markdown 文件',
 
     // Permissions and restrictions
     'permissions' => '权限',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => '书架权限',
     'shelves_permissions_updated' => '书架权限已更新',
     'shelves_permissions_active' => '书架权限激活',
+    'shelves_permissions_cascade_warning' => '书架上的权限不会自动应用到书架里的书。这是因为书可以在多个书架上存在。使用下面的选项可以将权限复制到书架里的书上。',
     'shelves_copy_permissions_to_books' => '将权限复制到图书',
     'shelves_copy_permissions' => '复制权限',
     'shelves_copy_permissions_explain' => '这会将此书架的当前权限设置应用于其中包含的所有图书。 在激活之前,请确保已保存对此书架权限的任何更改。',
@@ -104,7 +108,7 @@ return [
     // Books
     'book' => '图书',
     'books' => '图书',
-    'x_books' => ':count本书',
+    'x_books' => ':count 本书',
     'books_empty' => '不存在已创建的书',
     'books_popular' => '热门图书',
     'books_recent' => '最近的书',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => '章节倒序',
     'books_sort_show_other' => '显示其他图书',
     'books_sort_save' => '保存新顺序',
+    'books_copy' => '复制图书',
+    'books_copy_success' => '图书已成功复制',
 
     // Chapters
     'chapter' => '章节',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => '移动章节',
     'chapters_move_named' => '移动章节「:chapterName」',
     'chapter_move_success' => '章节移动到「:bookName」',
+    'chapters_copy' => '复制章节',
+    'chapters_copy_success' => '章节已成功复制',
     'chapters_permissions' => '章节权限',
     'chapters_empty' => '本章目前没有页面。',
     'chapters_permissions_active' => '有效的章节权限',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => '新页面',
     'pages_editing_draft_notification' => '您正在编辑在 :timeDiff 内保存的草稿.',
     'pages_draft_edited_notification' => '此后,此页面已经被更新,建议您放弃此草稿。',
+    'pages_draft_page_changed_since_creation' => '这个页面在您的草稿创建后被其他用户更新了,您目前的草稿不包含新的内容。建议您放弃此草稿,或是注意不要覆盖新的页面更改。',
     'pages_draft_edit_active' => [
         'start_a' => ':count位用户正在编辑此页面',
         'start_b' => '用户“:userName”已经开始编辑此页面',
@@ -249,10 +258,20 @@ return [
     'tag' => '标签',
     'tags' =>  '标签',
     'tag_name' =>  '标签名称',
-    'tag_value' => '标签值 (Optional)',
-    'tags_explain' => "添加一些标签以更好地对您的内容进行分类。\n您可以为标签分配一个值,以进行更深入的组织。",
+    'tag_value' => '标签值 (可选)',
+    'tags_explain' => "添加一些标签以更好地对您的内容进行分类。\n您可以为标签分配一个值,以进行更好的进行管理。",
     'tags_add' => '添加另一个标签',
     'tags_remove' => '删除此标签',
+    'tags_usages' => '标签总使用量',
+    'tags_assigned_pages' => '有这个标签的页面',
+    'tags_assigned_chapters' => '有这个标签的章节',
+    'tags_assigned_books' => '有这个标签的图书',
+    'tags_assigned_shelves' => '有这个标签的书架',
+    'tags_x_unique_values' => ':count 个不重复项目',
+    'tags_all_values' => '所有值',
+    'tags_view_tags' => '查看标签',
+    'tags_view_existing_tags' => '查看已有的标签',
+    'tags_list_empty_hint' => '您可以在页面编辑器的侧边栏添加标签,或者在编辑图书、章节、书架时添加。',
     'attachments' => '附件',
     'attachments_explain' => '上传一些文件或附加一些链接显示在您的网页上。这些在页面的侧边栏中可见。',
     'attachments_explain_instant_save' => '这里的更改将立即保存。',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => '您确定要删除此修订版吗?',
     'revision_restore_confirm' => '您确定要恢复到此修订版吗?恢复后原有内容将会被替换。',
     'revision_delete_success' => '修订删除',
-    'revision_cannot_delete_latest' => '无法删除最新版本。'
+    'revision_cannot_delete_latest' => '无法删除最新版本。',
+
+    // Copy view
+    'copy_consider' => '复制内容时请注意以下事项。',
+    'copy_consider_permissions' => '自定义权限设置将不会被复制。',
+    'copy_consider_owner' => '您将成为所有已复制内容的所有者。',
+    'copy_consider_images' => '页面中的图像文件不会被复制,原始图像将保留它们与最初上传到的页面的关系。',
+    'copy_consider_attachments' => '页面中的附件不会被复制。',
+    'copy_consider_access' => '改变位置、所有者或权限可能会导致此内容被以前无法访问的人访问。',
 ];
index b41e21ac3e83b9858edece28d99428dfa581f2b8..569c8482c7ade30ce6885af52599cb429e434840 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => '无法找到有效Email地址,此用户数据由外部身份验证系统托管',
     'saml_invalid_response_id' => '来自外部身份验证系统的请求没有被本应用程序认证,在登录后返回上一页可能会导致此问题。',
     'saml_fail_authed' => '使用 :system 登录失败,登录系统未返回成功登录授权信息。',
+    'oidc_already_logged_in' => '您已经登陆了',
+    'oidc_user_not_registered' => '用户 :name 尚未注册,自助注册功能已被禁用',
+    'oidc_no_email_address' => '无法找到有效的 Email 地址,此用户数据由外部身份验证系统托管',
+    'oidc_fail_authed' => '使用 :system 登录失败,登录系统未返回成功登录授权信息',
     'social_no_action_defined' => '没有定义行为',
     'social_login_bad_response' => "在 :socialAccount 登录时遇到错误:\n:error",
     'social_account_in_use' => ':socialAccount 账户已被使用,请尝试通过 :socialAccount 选项登录。',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => '无法找到页面',
     'sorry_page_not_found' => '对不起,无法找到您想访问的页面。',
     'sorry_page_not_found_permission_warning' => '您可能没有查看权限。',
+    'image_not_found' => '未找到图片',
+    'image_not_found_subtitle' => '对不起,无法找到您想访问的图片。',
+    'image_not_found_details' => '原本放在这里的图片已被删除。',
     'return_home' => '返回主页',
     'error_occurred' => '出现错误',
     'app_down' => ':appName现在正在关闭',
index ed222d7f492a9ac2d7ca82573f34f39a1484f3c8..064b166501131a887629fe4d424df9a40f9da489 100755 (executable)
@@ -15,7 +15,7 @@ return [
     'app_customization' => '定制',
     'app_features_security' => '功能与安全',
     'app_name' => '站点名称',
-    'app_name_desc' => '此名称将在网页头部和Email中显示。',
+    'app_name_desc' => '此名称将在网页头部和系统发送的电子邮件中显示。',
     'app_name_header' => '在网页头部显示站点名称?',
     'app_public_access' => '访问权限',
     'app_public_access_desc' => '启用此选项将允许未登录的用户访问站点内容。',
@@ -31,11 +31,11 @@ return [
     'app_custom_html_desc' => '此处添加的任何内容都将插入到每个页面的<head>部分的底部,这对于覆盖样式或添加分析代码很方便。',
     'app_custom_html_disabled_notice' => '在此设置页面上禁用了自定义HTML标题内容,以确保可以恢复所有重大更改。',
     'app_logo' => '站点Logo',
-    'app_logo_desc' => '这个图片的高度应该为43px。<br>大图片将会被缩小。',
+    'app_logo_desc' => '这个图片的高度应为 43 像素。<br>大图片将会被缩小。',
     'app_primary_color' => '站点主色',
     'app_primary_color_desc' => '这应该是一个十六进制值。<br>保留为空以重置为默认颜色。',
     'app_homepage' => '站点主页',
-    'app_homepage_desc' => '选择要在主页上显示的页面来替换默认的视图,选定页面的访问权限将被忽略。',
+    'app_homepage_desc' => '选择要在主页上显示的页面来替换默认的页面,选定页面的访问权限将被忽略。',
     'app_homepage_select' => '选择一个页面',
     'app_footer_links' => '页脚链接',
     'app_footer_links_desc' => '添加在网站页脚中显示的链接。这些链接将显示在大多数页面的底部,也包括不需要登录的页面。您可以使用标签"trans::<key>"来使用系统定义的翻译。例如:使用"trans::common.privacy_policy"将显示为“隐私政策”,而"trans::common.terms_of_service"将显示为“服务条款”。',
@@ -64,15 +64,15 @@ return [
     'reg_enable_external_warning' => '当启用外部LDAP或者SAML认证时,上面的选项会被忽略。当使用外部系统认证认证成功时,将自动创建非现有会员的用户账户。',
     'reg_email_confirmation' => '邮件确认',
     'reg_email_confirmation_toggle' => '需要电子邮件确认',
-    'reg_confirm_email_desc' => '如果使用域名限制,则需要Email验证,并且该值将被忽略。',
+    'reg_confirm_email_desc' => '如果使用域名限制,则需要电子邮件验证,并且该值将被忽略。',
     'reg_confirm_restrict_domain' => '域名限制',
-    'reg_confirm_restrict_domain_desc' => '输入您想要限制注册的Email域名列表,用逗号隔开。在被允许与应用程序交互之前,用户将被发送一封Email来确认他们的地址。<br>注意用户在注册成功后可以修改他们的Email地址。',
+    'reg_confirm_restrict_domain_desc' => '输入您想要限制注册的电子邮件域名列表(即只允许使用这些电子邮件域名注册),多个域名用英文逗号隔开。在允许用户与应用程序交互之前,系统将向用户发送一封电子邮件以确认其电子邮件地址。<br>请注意,用户在注册成功后仍然可以更改他们的电子邮件地址。',
     'reg_confirm_restrict_domain_placeholder' => '尚未设置限制',
 
     // Maintenance settings
     'maint' => '维护',
     'maint_image_cleanup' => '清理图像',
-    'maint_image_cleanup_desc' => "扫描页面和修订内容以检查哪些图像是正在使用的以及哪些图像是多余的。确保在运行前创建完整的数据库和映像备份。",
+    'maint_image_cleanup_desc' => '扫描页面和修订内容以检查哪些图片是正在使用的以及哪些图片是多余的。确保在运行前完整备份数据库和图片。',
     'maint_delete_images_only_in_revisions' => '同时删除只存在于旧的页面修订中的图片',
     'maint_image_cleanup_run' => '运行清理',
     'maint_image_cleanup_warning' => '发现了 :count 张可能未使用的图像。您确定要删除这些图像吗?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => '回收站',
     'recycle_bin_desc' => '在这里,您可以还原已删除的项目,或选择将其从系统中永久删除。与系统中过滤过的类似的活动记录不同,这个表会显示所有操作。',
     'recycle_bin_deleted_item' => '被删除的项目',
+    'recycle_bin_deleted_parent' => '上级',
     'recycle_bin_deleted_by' => '删除者',
     'recycle_bin_deleted_at' => '删除时间',
     'recycle_bin_permanently_delete' => '永久删除',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => '要恢复的项目',
     'recycle_bin_restore_confirm' => '此操作会将已删除的项目及其所有子元素恢复到原始位置。如果项目的原始位置已被删除,并且现在位于回收站中,则要恢复项目的上级项目也需要恢复。',
     'recycle_bin_restore_deleted_parent' => '该项目的上级项目也已被删除。这些项目将保持被删除状态,直到上级项目被恢复。',
+    'recycle_bin_restore_parent' => '还原上级',
     'recycle_bin_destroy_notification' => '从回收站中删除了 :count 个项目。',
     'recycle_bin_restore_notification' => '从回收站中恢复了 :count 个项目。',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => '用户',
     'audit_table_event' => '事件',
     'audit_table_related' => '相关项目或详细信息',
+    'audit_table_ip' => 'IP地址',
     'audit_table_date' => '活动日期',
     'audit_date_from' => '日期范围从',
     'audit_date_to' => '日期范围至',
@@ -136,6 +139,7 @@ return [
     'role_details' => '角色详细信息',
     'role_name' => '角色名',
     'role_desc' => '角色简述',
+    'role_mfa_enforced' => '需要多重身份认证',
     'role_external_auth_id' => '外部身份认证ID',
     'role_system' => '系统权限',
     'role_manage_users' => '管理用户',
@@ -145,8 +149,9 @@ return [
     'role_manage_page_templates' => '管理页面模板',
     'role_access_api' => '访问系统 API',
     'role_manage_settings' => '管理App设置',
+    'role_export_content' => '导出内容',
     'role_asset' => '资源许可',
-    'roles_system_warning' => '请注意,具有上述三个权限中的任何一个都可以允许用户更改自己的特权或系统中其他人的特权。 只将具有这些权限的角色分配给受信任的用户。',
+    'roles_system_warning' => '请注意,拥有上述三个权限中的任何一个都可以允许用户更改自己的权限或系统中其他人的权限。 请只将拥有这些权限的角色分配给你信任的用户。',
     'role_asset_desc' => '对系统内资源的默认访问许可将由这些权限控制。单独设置在书籍,章节和页面上的权限将覆盖这里的权限设定。',
     'role_asset_admins' => '管理员可自动获得对所有内容的访问权限,但这些选项可能会显示或隐藏UI选项。',
     'role_all' => '全部的',
@@ -169,7 +174,7 @@ return [
     'users_role' => '用户角色',
     'users_role_desc' => '选择将分配给该用户的角色。 如果将一个用户分配给多个角色,则这些角色的权限将堆叠在一起,并且他们将获得分配的角色的所有功能。',
     'users_password' => '用户密码',
-    'users_password_desc' => '设置用于登录应用程序的密码。 该长度必须至少为6个字符。',
+    'users_password_desc' => '设置用于登录本应用的密码。 长度必须至少为 8 个字符。',
     'users_send_invite_text' => '您可以向该用户发送邀请电子邮件,允许他们设置自己的密码,否则,您可以自己设置他们的密码。',
     'users_send_invite_option' => '发送邀请用户电子邮件',
     'users_external_auth_id' => '外部身份认证ID',
@@ -188,7 +193,7 @@ return [
     'users_edit_profile' => '编辑资料',
     'users_edit_success' => '用户更新成功',
     'users_avatar' => '用户头像',
-    'users_avatar_desc' => '当前图片应该为约256px的正方形。',
+    'users_avatar_desc' => '选择一张头像。 这张图片应该是约 256 像素的正方形。',
     'users_preferred_language' => '语言',
     'users_preferred_language_desc' => '此选项将更改用于应用程序用户界面的语言。 这不会影响任何用户创建的内容。',
     'users_social_accounts' => '社交账户',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => '创建令牌',
     'users_api_tokens_expires' => '过期',
     'users_api_tokens_docs' => 'API文档',
+    'users_mfa' => '多重身份认证',
+    'users_mfa_desc' => '设置多重身份认证能增加您账户的安全性。',
+    'users_mfa_x_methods' => ':count 个措施已配置|:count 个措施已配置',
+    'users_mfa_configure' => '配置安全措施',
 
     // API Tokens
     'user_api_token_create' => '创建 API 令牌',
@@ -220,10 +229,38 @@ return [
     'user_api_token_created' => '创建的令牌:timeAgo',
     'user_api_token_updated' => '令牌更新:timeAgo',
     'user_api_token_delete' => '删除令牌',
-    'user_api_token_delete_warning' => '这将会从系统中完全删除名为“令牌命名”的API令牌',
+    'user_api_token_delete_warning' => '这将会从系统中完全删除名为 “:tokenName” 的 API 令牌',
     'user_api_token_delete_confirm' => '您确定要删除此API令牌吗?',
     'user_api_token_delete_success' => '成功删除API令牌',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => '新建 Webhook',
+    'webhooks_none_created' => '尚未创建任何 webhook',
+    'webhooks_edit' => '编辑 Webhook',
+    'webhooks_save' => '保存 Webhook',
+    'webhooks_details' => 'Webhook 详情',
+    'webhooks_details_desc' => '提供一个用户友好的名称和一个 POST endpoint 作为 webhook 数据发送的位置。',
+    'webhooks_events' => 'Webhook 事件',
+    'webhooks_events_desc' => '选择所有应触发此 webhook 的事件。',
+    'webhooks_events_warning' => '请记住,即使应用了自定义权限,所有选定的事件也仍然会被触发。 确保使用此 webhook 不会泄露机密内容。',
+    'webhooks_events_all' => '所有系统事件',
+    'webhooks_name' => 'Webhook 名称',
+    'webhooks_timeout' => 'Webhook 请求超时(秒)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => '激活 Webhook',
+    'webhook_events_table_header' => '事件',
+    'webhooks_delete' => '删除 Webhook',
+    'webhooks_delete_warning' => '这将会从系统中完全删除名为 “:webhookName” 的 webhook。',
+    'webhooks_delete_confirm' => '您确定要删除此 Webhook 吗?',
+    'webhooks_format_example' => 'Webhook 格式示例',
+    'webhooks_format_example_desc' => 'Webhook 数据会用 POST 请求按照以下 JSON 格式发送到设置的 endpoint。 “related_item” 和 “url” 属性是可选的,取决于触发的事件类型。',
+    'webhooks_status' => 'Webhook 状态',
+    'webhooks_last_called' => '最后一次调用:',
+    'webhooks_last_errored' => '最后一个错误:',
+    'webhooks_last_error_message' => '最后一个错误消息:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => 'עברית',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => '挪威语 (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 72b0d594e75dd852b8002bcdb91b2c8034db5c3d..3398a1142c0a2ea63eb08c174653f2a3b3d30bb8 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute 只能包含字母、数字和短横线。',
     'alpha_num'            => ':attribute 只能包含字母和数字。',
     'array'                => ':attribute 必须是一个数组。',
+    'backup_codes'         => '您输入的认证码无效或已被使用。',
     'before'               => ':attribute 必须是在 :date 前的日期。',
     'between'              => [
         'numeric' => ':attribute 必须在:min到:max之间。',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute 必须是字符串。',
     'timezone'             => ':attribute 必须是有效的区域。',
+    'totp'                 => '您输入的认证码无效或已过期。',
     'unique'               => ':attribute 已经被使用。',
     'url'                  => ':attribute 格式无效。',
     'uploaded'             => '无法上传文件。 服务器可能不接受此大小的文件。',
index 0c86665b03df03f90b79e0fc65f20f95fc2770c7..9df1a57661995952e104ae7530bca990f919d57f 100644 (file)
@@ -7,41 +7,57 @@ return [
 
     // Pages
     'page_create'                 => '已建立頁面',
-    'page_create_notification'    => '頁面已建立成功',
+    'page_create_notification'    => 'Page successfully created',
     'page_update'                 => '已更新頁面',
-    'page_update_notification'    => '頁面已更新成功',
+    'page_update_notification'    => 'Page successfully updated',
     'page_delete'                 => '已刪除頁面',
-    'page_delete_notification'    => '頁面已刪除成功',
+    'page_delete_notification'    => 'Page successfully deleted',
     'page_restore'                => '已還原頁面',
-    'page_restore_notification'   => '頁面已還原成功',
+    'page_restore_notification'   => 'Page successfully restored',
     'page_move'                   => '已移動頁面',
 
     // Chapters
     'chapter_create'              => '已建立章節',
-    'chapter_create_notification' => '章節已建立成功',
+    'chapter_create_notification' => 'Chapter successfully created',
     'chapter_update'              => '已更新章節',
-    'chapter_update_notification' => '章節已建立成功',
+    'chapter_update_notification' => 'Chapter successfully updated',
     'chapter_delete'              => '已刪除章節',
-    'chapter_delete_notification' => '章節已刪除成功',
+    'chapter_delete_notification' => 'Chapter successfully deleted',
     'chapter_move'                => '已移動章節',
 
     // Books
     'book_create'                 => '已建立書本',
-    'book_create_notification'    => '書本已建立成功',
+    'book_create_notification'    => 'Book successfully created',
     'book_update'                 => '已更新書本',
-    'book_update_notification'    => '書本已更新成功',
+    'book_update_notification'    => 'Book successfully updated',
     'book_delete'                 => '已刪除書本',
-    'book_delete_notification'    => '書本已刪除成功',
+    'book_delete_notification'    => 'Book successfully deleted',
     'book_sort'                   => '已排序書本',
-    'book_sort_notification'      => '書本已重新排序成功',
+    'book_sort_notification'      => 'Book successfully re-sorted',
 
     // Bookshelves
-    'bookshelf_create'            => '已建立書架',
-    'bookshelf_create_notification'    => '書架已建立成功',
+    'bookshelf_create'            => 'created bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf successfully created',
     'bookshelf_update'                 => '已更新書架',
-    'bookshelf_update_notification'    => '書架已更新成功',
+    'bookshelf_update_notification'    => 'Bookshelf successfully updated',
     'bookshelf_delete'                 => '已刪除書架',
-    'bookshelf_delete_notification'    => '書架已刪除成功',
+    'bookshelf_delete_notification'    => 'Bookshelf successfully deleted',
+
+    // Favourites
+    'favourite_add_notification' => '":name" 已加入到你的最愛',
+    'favourite_remove_notification' => '":name" 已從你的最愛移除',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Webhooks
+    'webhook_create' => 'created webhook',
+    'webhook_create_notification' => 'Webhook successfully created',
+    'webhook_update' => 'updated webhook',
+    'webhook_update_notification' => 'Webhook successfully updated',
+    'webhook_delete' => 'deleted webhook',
+    'webhook_delete_notification' => 'Webhook successfully deleted',
 
     // Other
     'commented_on'                => '評論',
index e4f3c79782b6da909c5a0b5e039cfa901c319ead..b051deda52849229c433f56ccf985a0b495937a6 100644 (file)
@@ -21,7 +21,7 @@ return [
     'email' => '電子郵件',
     'password' => '密碼',
     'password_confirm' => '確認密碼',
-    'password_hint' => '必須超過 7 個字元',
+    'password_hint' => 'Must be at least 8 characters',
     'forgot_password' => '忘記密碼?',
     'remember_me' => '記住我',
     'ldap_email_hint' => '請輸入此帳號使用的電子郵件。',
@@ -38,7 +38,6 @@ return [
     'registration_email_domain_invalid' => '這個電子郵件網域沒有權限使用',
     'register_success' => '感謝您註冊!您已註冊完成並可登入。',
 
-
     // Password Reset
     'reset_password' => '重設密碼',
     'reset_password_send_instructions' => '在下方輸入您的電子郵件,您將收到一封帶有密碼重設連結的郵件。',
@@ -49,14 +48,13 @@ return [
     'email_reset_text' => '您收到此電子郵件是因為我們收到了您的帳號的密碼重設請求。',
     'email_reset_not_requested' => '如果您沒有要求重設密碼,則不需要採取進一步的操作。',
 
-
     // Email Confirmation
     'email_confirm_subject' => '確認您在 :appName 的電子郵件',
     'email_confirm_greeting' => '感謝您加入 :appName!',
     'email_confirm_text' => '請點選下面的按鈕來確認您的電子郵件地址:',
     'email_confirm_action' => '確認電子郵件',
     'email_confirm_send_error' => '需要電子郵件驗證,但系統無法傳送電子郵件。請與管理員聯絡以確保電子郵件正確設定。',
-    'email_confirm_success' => '您的電子郵件已成功驗證!',
+    'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
     'email_confirm_resent' => '確認電子郵件已重新傳送。請檢查您的收件匣。',
 
     'email_not_confirmed' => '電子郵件地址未確認',
@@ -73,5 +71,40 @@ return [
     'user_invite_page_welcome' => '歡迎使用 :appName!',
     'user_invite_page_text' => '要完成設定您的帳號並取得存取權,您必須設定密碼,此密碼將用於登入 :appName。',
     'user_invite_page_confirm_button' => '確認密碼',
-    'user_invite_success' => '密碼已設定,您現在可以存取 :appName 了!'
-];
\ No newline at end of file
+    'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
+
+    // Multi-factor Authentication
+    'mfa_setup' => 'Setup Multi-Factor Authentication',
+    'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'mfa_setup_configured' => 'Already configured',
+    'mfa_setup_reconfigure' => 'Reconfigure',
+    'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
+    'mfa_setup_action' => 'Setup',
+    'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
+    'mfa_option_totp_title' => 'Mobile App',
+    'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_option_backup_codes_title' => 'Backup Codes',
+    'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
+    'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
+    'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
+    'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
+    'mfa_gen_backup_codes_download' => 'Download Codes',
+    'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
+    'mfa_gen_totp_title' => 'Mobile App Setup',
+    'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
+    'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
+    'mfa_gen_totp_verify_setup' => 'Verify Setup',
+    'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
+    'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
+    'mfa_verify_access' => 'Verify Access',
+    'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
+    'mfa_verify_no_methods' => 'No Methods Configured',
+    '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_use_totp' => 'Verify using a mobile app',
+    'mfa_verify_use_backup_codes' => 'Verify using a backup code',
+    'mfa_verify_backup_code' => 'Backup Code',
+    'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
+    'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
+    'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
+    'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
+];
index ac2a49dccb75cef4f0fe76174efa021190ce6f30..663089f23646cbbb4d1ffd69a94be335bfcb3ac1 100644 (file)
@@ -20,7 +20,7 @@ return [
     'role' => '角色',
     'cover_image' => '封面圖片',
     'cover_image_description' => '此圖片大小應約為 440x250px。',
-    
+
     // Actions
     'actions' => '動作',
     'view' => '檢視',
@@ -39,7 +39,14 @@ return [
     'reset' => '重設',
     'remove' => '移除',
     'add' => '新增',
+    'configure' => 'Configure',
     'fullscreen' => '全螢幕',
+    'favourite' => '最愛',
+    'unfavourite' => '取消最愛',
+    'next' => '下一頁',
+    'previous' => '上一頁',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => '排序選項',
@@ -47,7 +54,7 @@ return [
     'sort_ascending' => '遞增排序',
     'sort_descending' => '遞減排序',
     'sort_name' => '名稱',
-    'sort_default' => 'Default',
+    'sort_default' => '預設',
     'sort_created_at' => '建立日期',
     'sort_updated_at' => '更新日期',
 
@@ -56,6 +63,7 @@ return [
     'no_activity' => '無活動可顯示',
     'no_items' => '無可用項目',
     'back_to_top' => '回到頂端',
+    'skip_to_main_content' => '跳到主內容',
     'toggle_details' => '顯示/隱藏詳細資訊',
     'toggle_thumbnails' => '顯示/隱藏縮圖',
     'details' => '詳細資訊',
@@ -63,9 +71,14 @@ return [
     'list_view' => '列表檢視',
     'default' => '預設',
     'breadcrumb' => '頁面路徑',
+    'status' => 'Status',
+    'status_active' => 'Active',
+    'status_inactive' => 'Inactive',
+    'never' => 'Never',
+    'none' => 'None',
 
     // Header
-    'header_menu_expand' => 'Expand Header Menu',
+    'header_menu_expand' => '展開選單',
     'profile_menu' => '個人資料選單',
     'view_profile' => '檢視個人資料',
     'edit_profile' => '編輯個人資料',
@@ -74,9 +87,9 @@ return [
 
     // Layout tabs
     'tab_info' => '資訊',
-    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_info_label' => '顯示次要訊息',
     'tab_content' => '內容',
-    'tab_content_label' => 'Tab: Show Primary Content',
+    'tab_content_label' => '顯示主要內容',
 
     // Email Content
     'email_action_help' => '如果您無法點擊 ":actionText" 按鈕,請將下方的網址複製並貼上到您的網路瀏覽器中:',
index 564cd3891d2a59bbcfad668999dce98aa0ca805c..0b10aaa8301df570350aecc420ae20d4e7240cc1 100644 (file)
@@ -27,6 +27,8 @@ return [
     'images' => '圖片',
     'my_recent_drafts' => '我最近的草稿',
     'my_recently_viewed' => '我最近檢視',
+    'my_most_viewed_favourites' => '我瀏覽最多次的最愛',
+    'my_favourites' => '我的最愛',
     'no_pages_viewed' => '您尚未看過任何頁面',
     'no_pages_recently_created' => '最近未建立任何頁面',
     'no_pages_recently_updated' => '最近沒有頁面被更新',
@@ -34,6 +36,7 @@ return [
     'export_html' => '網頁檔案',
     'export_pdf' => 'PDF 檔案',
     'export_text' => '純文字檔案',
+    'export_md' => 'Markdown 檔案',
 
     // Permissions and restrictions
     'permissions' => '權限',
@@ -60,7 +63,7 @@ return [
     'search_permissions_set' => '權限設定',
     'search_created_by_me' => '我建立的',
     'search_updated_by_me' => '我更新的',
-    'search_owned_by_me' => 'Owned by me',
+    'search_owned_by_me' => '我所擁有的',
     'search_date_options' => '日期選項',
     'search_updated_before' => '在此之前更新',
     'search_updated_after' => '在此之後更新',
@@ -96,6 +99,7 @@ return [
     'shelves_permissions' => '書架權限',
     'shelves_permissions_updated' => '書架權限已更新',
     'shelves_permissions_active' => '書架權限已啟用',
+    'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
     'shelves_copy_permissions_to_books' => '將權限複製到書本',
     'shelves_copy_permissions' => '複製權限',
     'shelves_copy_permissions_explain' => '這會將此書架目前的權限設定套用到所有包含的書本上。在啟用前,請確認您已儲存任何對此書架權限的變更。',
@@ -139,6 +143,8 @@ return [
     'books_sort_chapters_last' => '最後一章',
     'books_sort_show_other' => '顯示其他書本',
     'books_sort_save' => '儲存新順序',
+    'books_copy' => 'Copy Book',
+    'books_copy_success' => 'Book successfully copied',
 
     // Chapters
     'chapter' => '章節',
@@ -157,6 +163,8 @@ return [
     'chapters_move' => '移動章節',
     'chapters_move_named' => '移動章節 :chapterName',
     'chapter_move_success' => '章節移動到 :bookName',
+    'chapters_copy' => 'Copy Chapter',
+    'chapters_copy_success' => 'Chapter successfully copied',
     'chapters_permissions' => '章節權限',
     'chapters_empty' => '本章目前沒有頁面。',
     'chapters_permissions_active' => '章節權限已啟用',
@@ -230,6 +238,7 @@ return [
     'pages_initial_name' => '新頁面',
     'pages_editing_draft_notification' => '您正在編輯最後儲存為 :timeDiff 的草稿。',
     'pages_draft_edited_notification' => '此頁面已經被更新過。建議您放棄此草稿。',
+    'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
     'pages_draft_edit_active' => [
         'start_a' => ':count 位使用者已經開始編輯此頁面',
         'start_b' => '使用者 :userName 已經開始編輯此頁面',
@@ -253,6 +262,16 @@ return [
     'tags_explain' => "加入一些標籤以更好地對您的內容進行分類。 \n 您可以為標籤分配一個值,以進行更深入的組織。",
     'tags_add' => '新增另一個標籤',
     'tags_remove' => '移除此標籤',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
+    'tags_view_tags' => 'View Tags',
+    'tags_view_existing_tags' => 'View existing tags',
+    'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
     'attachments' => '附件',
     'attachments_explain' => '上傳一些檔案或附加連結以顯示在您的網頁上。將顯示在在頁面的側邊欄。',
     'attachments_explain_instant_save' => '此處的變動將會立刻儲存。',
@@ -316,5 +335,13 @@ return [
     'revision_delete_confirm' => '您確定要刪除此修訂版本嗎?',
     'revision_restore_confirm' => '您確定要還原此修訂版本嗎? 目前頁面內容將被替換。',
     'revision_delete_success' => '修訂版本已刪除',
-    'revision_cannot_delete_latest' => '無法刪除最新修訂版本。'
+    'revision_cannot_delete_latest' => '無法刪除最新修訂版本。',
+
+    // Copy view
+    'copy_consider' => 'Please consider the below when copying content.',
+    'copy_consider_permissions' => 'Custom permission settings will not be copied.',
+    'copy_consider_owner' => 'You will become the owner of all copied content.',
+    'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
+    'copy_consider_attachments' => 'Page attachments will not be copied.',
+    'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
 ];
index cdb346e2c4adf3b943e991bd3e8057cd87932948..2a4483054538ef958d8eabf4d0c83f4baa8a3073 100644 (file)
@@ -23,6 +23,10 @@ return [
     'saml_no_email_address' => '在外部認證系統提供的資料中找不到該使用者的電子郵件地址',
     'saml_invalid_response_id' => '此應用程式啟動的處理程序無法識別來自外部認證系統的請求。登入後回上一頁可能會造成此問題。',
     'saml_fail_authed' => '使用 :system 登入失敗,系統未提供成功的授權',
+    'oidc_already_logged_in' => 'Already logged in',
+    'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
+    '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' => '未定義動作',
     'social_login_bad_response' => "在 :socialAccount 登入時遇到錯誤: \n:error",
     'social_account_in_use' => ':socialAccount 帳號已被使用,請嘗試透過 :socialAccount 選項登入。',
@@ -83,6 +87,9 @@ return [
     '404_page_not_found' => '找不到頁面',
     'sorry_page_not_found' => '抱歉,找不到您在尋找的頁面。',
     'sorry_page_not_found_permission_warning' => '如果您確認這個頁面存在,則代表可能沒有查看它的權限。',
+    'image_not_found' => '找不到圖片',
+    'image_not_found_subtitle' => '對不起,無法找到您所看的圖片',
+    'image_not_found_details' => '原本的圖片可能已經被刪除',
     'return_home' => '回到首頁',
     'error_occurred' => '發生錯誤',
     'app_down' => ':appName 離線中',
index 278049052290778ffdd3fe90126d6d4592e173a2..ad12825566aadd811657859100b13c71b9814357 100644 (file)
@@ -72,7 +72,7 @@ return [
     // Maintenance settings
     'maint' => '維護',
     'maint_image_cleanup' => '清理圖片',
-    'maint_image_cleanup_desc' => "掃描頁面與修訂版本內容來檢查目前使用了哪些圖片,而哪些圖片又是多餘的。請確保您在執行這個動作前建立了完整的資料庫與映像檔備份。",
+    'maint_image_cleanup_desc' => '掃描頁面與修訂版本內容來檢查目前使用了哪些圖片,而哪些圖片又是多餘的。請確保您在執行這個動作前建立了完整的資料庫與映像檔備份。',
     'maint_delete_images_only_in_revisions' => '也刪除僅存在於舊的頁面修訂版本中存在的圖片',
     'maint_image_cleanup_run' => '執行清理',
     'maint_image_cleanup_warning' => '發現了 :count 張可能未使用的圖片。您確定要刪除這些圖片嗎?',
@@ -92,6 +92,7 @@ return [
     'recycle_bin' => '資源回收桶',
     'recycle_bin_desc' => '在這裡,您可以還原已刪除的項目,或是選擇將其從系統中永久移除。與系統中套用了權限過濾條件類似的活動列表不同的是,此列表並未過濾。',
     'recycle_bin_deleted_item' => '已刪除項目',
+    'recycle_bin_deleted_parent' => '上層',
     'recycle_bin_deleted_by' => '刪除由',
     'recycle_bin_deleted_at' => '刪除時間',
     'recycle_bin_permanently_delete' => '永久刪除',
@@ -104,6 +105,7 @@ return [
     'recycle_bin_restore_list' => '要被還原的項目',
     'recycle_bin_restore_confirm' => '此動作將會還原已被刪除的項目(包含任何下層元素)到其原始位置。如果原始位置已被刪除,且目前位於垃圾桶裡,那麼上層項目也需要被還原。',
     'recycle_bin_restore_deleted_parent' => '此項目的上層項目也已被刪除。因此將會保持被刪除的狀態,直到上層項目也被還原。',
+    'recycle_bin_restore_parent' => '還原上層',
     'recycle_bin_destroy_notification' => '已從回收桶刪除共 :count 個項目。',
     'recycle_bin_restore_notification' => '已從回收桶還原共 :count 個項目。',
 
@@ -117,6 +119,7 @@ return [
     'audit_table_user' => '使用者',
     'audit_table_event' => '活動',
     'audit_table_related' => '相關的項目或詳細資訊',
+    'audit_table_ip' => 'IP Address',
     'audit_table_date' => '活動日期',
     'audit_date_from' => '日期範圍,從',
     'audit_date_to' => '日期範圍,到',
@@ -136,6 +139,7 @@ return [
     'role_details' => '角色詳細資訊',
     'role_name' => '角色名稱',
     'role_desc' => '角色簡短說明',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => '外部身份驗證 ID',
     'role_system' => '系統權限',
     'role_manage_users' => '管理使用者',
@@ -145,6 +149,7 @@ return [
     'role_manage_page_templates' => '管理頁面範本',
     'role_access_api' => '存取系統 API',
     'role_manage_settings' => '管理應用程式設定',
+    'role_export_content' => 'Export content',
     'role_asset' => '資源權限',
     'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。',
     'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。',
@@ -169,7 +174,7 @@ return [
     'users_role' => '使用者角色',
     'users_role_desc' => '選取要分配的此使用者的角色。若使用者被分配到多個角色,則這些角色的權限將會堆疊,使用者將會取得被分配角色的所有功能。',
     'users_password' => '使用者密碼',
-    'users_password_desc' => '設定用於登入應用程式的密碼。密碼必須至少 6 個字元長。',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
     'users_send_invite_text' => '您可以選擇向此使用者傳送邀請電子郵件,讓他們可以設定自己的密碼,您也可以自行設定他們的密碼。',
     'users_send_invite_option' => '傳送邀請電子郵件給使用者',
     'users_external_auth_id' => '外部身份驗證 ID',
@@ -202,6 +207,10 @@ return [
     'users_api_tokens_create' => '建立權杖',
     'users_api_tokens_expires' => '過期',
     'users_api_tokens_docs' => 'API 文件',
+    'users_mfa' => 'Multi-Factor Authentication',
+    'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Configure Methods',
 
     // API Tokens
     'user_api_token_create' => '建立 API 權杖',
@@ -224,6 +233,34 @@ return [
     'user_api_token_delete_confirm' => '您確定要刪除此 API 權杖嗎?',
     'user_api_token_delete_success' => 'API 權杖已成功刪除',
 
+    // Webhooks
+    'webhooks' => 'Webhooks',
+    'webhooks_create' => 'Create New Webhook',
+    'webhooks_none_created' => 'No webhooks have yet been created.',
+    'webhooks_edit' => 'Edit Webhook',
+    'webhooks_save' => 'Save Webhook',
+    'webhooks_details' => 'Webhook Details',
+    'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
+    'webhooks_events' => 'Webhook Events',
+    'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
+    'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
+    'webhooks_events_all' => 'All system events',
+    'webhooks_name' => 'Webhook Name',
+    'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
+    'webhooks_endpoint' => 'Webhook Endpoint',
+    'webhooks_active' => 'Webhook Active',
+    'webhook_events_table_header' => 'Events',
+    'webhooks_delete' => 'Delete Webhook',
+    'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
+    'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
+    'webhooks_format_example' => 'Webhook Format Example',
+    'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
+    'webhooks_status' => 'Webhook Status',
+    'webhooks_last_called' => 'Last Called:',
+    'webhooks_last_errored' => 'Last Errored:',
+    'webhooks_last_error_message' => 'Last Error Message:',
+
+
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
     //!////////////////////////////////
@@ -239,13 +276,16 @@ return [
         'de_informal' => 'Deutsch (Du)',
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
+        'et' => 'Eesti keel',
         'fr' => 'Français',
         'he' => '希伯來語',
+        'hr' => 'Hrvatski',
         'hu' => 'Magyar',
         'id' => 'Bahasa Indonesia',
         'it' => 'Italian',
         'ja' => '日本語',
         'ko' => '한국어',
+        'lt' => 'Lietuvių Kalba',
         'lv' => 'Latviešu Valoda',
         'nl' => 'Nederlands',
         'nb' => 'Norsk (Bokmål)',
@@ -261,6 +301,6 @@ return [
         'vi' => 'Tiếng Việt',
         'zh_CN' => '简体中文',
         'zh_TW' => '繁體中文',
-    ]
+    ],
     //!////////////////////////////////
 ];
index 691ebb619652058994c356803665d5ba4370fcf2..e93c182ee4fe600f75b4d57e35401557c157036f 100644 (file)
@@ -15,6 +15,7 @@ return [
     'alpha_dash'           => ':attribute 只能包含字母、數字、破折號與底線。',
     'alpha_num'            => ':attribute 只能包含字母和數字。',
     'array'                => ':attribute 必須是陣列。',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute 必須是在 :date 前的日期。',
     'between'              => [
         'numeric' => ':attribute 必須在 :min 到 :max 之間。',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute 必須是字元串。',
     'timezone'             => ':attribute 必須是有效的區域。',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute 已經被使用。',
     'url'                  => ':attribute 格式無效。',
     'uploaded'             => '無法上傳文件, 服務器可能不接受此大小的文件。',
index 75adf12aacde34a99b795c7278320f6ebdfee62b..0a7a689f7dec53043ffbf576ba3c418f06eb6d43 100644 (file)
 .card-title a {
   line-height: 1;
 }
+.card-footer-link {
+  display: block;
+  padding: $-s $-m;
+  line-height: 1;
+  border-top: 1px solid;
+  @include lightDark(border-color, #DDD, #555);
+  border-radius: 0 0 3px 3px;
+  font-size: 0.9em;
+  margin-top: $-xs;
+  &:hover {
+    text-decoration: none;
+    @include lightDark(background-color, #f2f2f2, #2d2d2d);
+  }
+}
 
 .card.border-card {
   border: 1px solid #DDD;
   padding: $-m $-xxl;
   margin-inline-start: auto;
   margin-inline-end: auto;
-  margin-bottom: $-xl;
+  margin-bottom: $-l;
   overflow: initial;
   min-height: 60vh;
   &.auto-height {
   }
 }
 
+.outline-hover {
+  border: 1px solid transparent !important;
+  &:hover {
+    border: 1px solid rgba(0, 0, 0, 0.1) !important;
+  }
+}
+
+.fade-in-when-active {
+  opacity: 0.6;
+  transition: opacity ease-in-out 120ms;
+  &:hover, &:focus-within {
+    opacity: 1;
+  }
+  @media (prefers-contrast: more) {
+    opacity: 1;
+  }
+}
+
 /**
  * Tags
  */
   margin-bottom: $-xs;
   margin-inline-end: $-xs;
   border-radius: 4px;
-  border: 1px solid #CCC;
+  border: 1px solid;
   overflow: hidden;
   font-size: 0.85em;
-  a, a:hover, a:active {
+  @include lightDark(border-color, #CCC, #666);
+  a, span, a:hover, a:active {
     padding: 4px 8px;
-    @include lightDark(color, #777, #999);
+    @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8));
     transition: background-color ease-in-out 80ms;
     text-decoration: none;
   }
     @include lightDark(background-color, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3));
   }
   svg {
-    fill: #888;
+    @include lightDark(fill, rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5));
   }
   .tag-value {
-    border-inline-start: 1px solid #DDD;
+    border-inline-start: 1px solid;
+    @include lightDark(border-color, #DDD, #666);
     @include lightDark(background-color, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.2))
   }
 }
 
+.tag-name.highlight, .tag-value.highlight {
+  font-weight: bold;
+}
+
 .tag-list div:last-child .tag-item {
   margin-bottom: 0;
 }
 
+td .tag-item {
+  margin-bottom: 0;
+}
+
+/**
+ * Pill boxes
+ */
+
+.pill {
+  display: inline-block;
+  border: 1px solid currentColor;
+  padding: .2em .8em;
+  font-size: 0.8em;
+  border-radius: 1rem;
+  position: relative;
+  overflow: hidden;
+  line-height: 1.4;
+  &:before {
+    content: '';
+    background-color: currentColor;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    opacity: 0.1;
+  }
+}
+
 /**
  * API Docs
  */
index bd76090523ca70a8ce24cba5ec5037ed7f5b74ae..850443d9a7c02a500832990eaa0b9d4e7a7ca8d5 100644 (file)
@@ -117,6 +117,7 @@ button {
   align-items: center;
   padding: $-s $-m;
   padding-bottom: ($-s - 2px);
+  width: 100%;
   svg {
     display: inline-block;
     width: 24px;
index e419ab524e63112c1c383996cc326644e4b6ab4c..330923d4fd18bd12f055b9ab2d39e5931e8214f9 100644 (file)
@@ -439,6 +439,7 @@ html.dark-mode .CodeMirror pre {
   width: 100%;
   height: 100%;
   margin-bottom: 0;
+  border: 0;
 }
 
 /**
index ad630469438f770dcb6c95c204801f70475274ab..95ba81520bc431eaac45a9b7dba22931126505a4 100644 (file)
@@ -190,7 +190,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   float: left;
   margin: 0;
   cursor: pointer;
-  width: (100%/6);
+  width: math.div(100%, 6);
   height: auto;
   @include lightDark(border-color, #ddd, #000);
   box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
@@ -219,7 +219,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
     }
   }
   @include smaller-than($xl) {
-    width: (100%/4);
+    width: math.div(100%, 4);
   }
   @include smaller-than($m) {
     .image-meta {
@@ -629,7 +629,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 
 .code-editor .lang-options {
-  max-width: 480px;
+  max-width: 540px;
   margin-bottom: $-s;
   a {
     margin-inline-end: $-xs;
index c42399de11efdf91ef0a1bd61761740aa48b96e6..665b1213be8ba5a4401b3449c801f0c627d5c207 100644 (file)
   }
 }
 
+.input-fill-width {
+  width: 100% !important;
+}
+
 .fake-input {
   @extend .input-base;
   overflow: auto;
 .markdown-editor-display {
   background-color: #fff;
   body {
+    display: block;
     background-color: #fff;
     padding-inline-start: 16px;
     padding-inline-end: 16px;
@@ -187,7 +192,7 @@ table.form-table {
   max-width: 100%;
   td {
     overflow: hidden;
-    padding: $-xxs/2 0;
+    padding: math.div($-xxs, 2) 0;
   }
 }
 
index 1a7015078e3891f44fa3511e0b88985073ce7f96..f070f5a187428eedc0a2af37688733efa1af4a28 100644 (file)
@@ -262,6 +262,9 @@ header .search-box {
   &:hover, &:focus-within {
     opacity: 1;
   }
+  @media (prefers-contrast: more) {
+    opacity: 1;
+  }
 }
 
 @include smaller-than($l) {
index 60205eaaacc3f42088800874b6760f963bc9ae26..783ccc8f9476cb455e71c6186c346e2695ba1b9e 100644 (file)
@@ -145,6 +145,7 @@ body.flexbox {
 .flex {
   min-height: 0;
   flex: 1;
+  max-width: 100%;
   &.fit-content {
     flex-basis: auto;
     flex-grow: 0;
@@ -157,6 +158,9 @@ body.flexbox {
 .justify-center {
   justify-content: center;
 }
+.justify-space-between {
+  justify-content: space-between;
+}
 .items-center {
   align-items: center;
 }
@@ -178,6 +182,10 @@ body.flexbox {
   display: inline-block !important;
 }
 
+.relative {
+  position: relative;
+}
+
 .hidden {
   display: none !important;
 }
@@ -209,6 +217,13 @@ body.flexbox {
   }
 }
 
+/**
+ * Border radiuses
+ */
+.rounded {
+  border-radius: 4px;
+}
+
 /**
  * Inline content columns
  */
@@ -354,6 +369,9 @@ body.flexbox {
     &:focus-within {
       opacity: 1;
     }
+    @media (prefers-contrast: more) {
+      opacity: 1;
+    }
   }
 
 }
@@ -363,4 +381,4 @@ body.flexbox {
     margin-inline-start: 0;
     margin-inline-end: 0;
   }
-}
+}
\ No newline at end of file
index d6ea66350ebecf15fd0a59b95528e713923a990c..c46ac84f35e45f09ebd89cf31482fb12230af8ea 100644 (file)
     padding-bottom: $-xxs;
     background-clip: content-box;
     border-radius: 0 3px 3px 0;
+    padding-inline-end: 0;
     .content {
       padding-top: $-xs;
       padding-bottom: $-xs;
   .entity-list-item-name {
     font-size: 1em;
     margin: 0;
+    margin-inline-end: $-m;
   }
   .chapter-child-menu {
     font-size: .8rem;
@@ -441,12 +443,8 @@ ul.pagination {
     background-color: rgba(0, 0, 0, 0.1);
     border-radius: 4px;
   }
-  &.outline-hover {
-    border: 1px solid transparent;
-  }
   &.outline-hover:hover {
     background-color: transparent;
-    border-color: rgba(0, 0, 0, 0.1);
   }
   &:focus {
     @include lightDark(background-color, #eee, #222);
@@ -550,6 +548,17 @@ ul.pagination {
   }
 }
 
+.entity-item-tags {
+  font-size: .75rem;
+  opacity: 1;
+  .primary-background-light {
+    background: transparent;
+  }
+  .tag-name {
+    background-color: rgba(0, 0, 0, 0.05);
+  }
+}
+
 .dropdown-container {
   display: inline-block;
   vertical-align: top;
@@ -675,4 +684,4 @@ ul.pagination {
     border-radius: 3px;
     text-decoration: none;
   }
-}
\ No newline at end of file
+}
index 4f249244b03f88a388c3b6c97855b01d27ac1d0a..14c253679419ba0981ea66fff5be999709360c56 100755 (executable)
@@ -412,4 +412,7 @@ body.mce-fullscreen, body.markdown-fullscreen {
     text-decoration: none;
     opacity: 1;
   }
+  @media (prefers-contrast: more) {
+    opacity: 1;
+  }
 }
\ No newline at end of file
index 315f979f3efdb2c215d8de56ab66609ca8e11e48..dd585733ce4b20b79a7748a9afc442c68c2dbbd7 100644 (file)
@@ -11,6 +11,7 @@ table {
     border: 1px solid #DDD;
     overflow: auto;
     line-height: 1.2;
+       word-break: break-word;
   }
   td p, th p {
     margin: 0;
@@ -34,7 +35,7 @@ table.table {
     font-weight: bold;
   }
   tr:hover {
-    @include lightDark(background-color, #eee, #333);
+    @include lightDark(background-color, #F2F2F2, #333);
   }
   .text-right {
     text-align: end;
@@ -48,6 +49,12 @@ table.table {
   a {
     display: inline-block;
   }
+  &.expand-to-padding {
+    margin-left: -$-s;
+    margin-right: -$-s;
+    width: calc(100% + (2*#{$-s}));
+    max-width: calc(100% + (2*#{$-s}));
+  }
 }
 
 table.no-style {
index 4322cb5a606da731f00a692ed97f1c8ee325a723..cbe3cd4be02b25158f261a293c9015274f25c8a6 100644 (file)
@@ -112,6 +112,13 @@ a {
   }
 }
 
+a.no-link-style {
+  color: inherit;
+  &:hover {
+    text-decoration: none;
+  }
+}
+
 .blended-links a {
   color: inherit;
   svg {
@@ -135,6 +142,9 @@ hr {
   &.faded {
     background-image: linear-gradient(to right, #FFF, #e3e0e0 20%, #e3e0e0 80%, #FFF);
   }
+  &.darker {
+    @include lightDark(background, #DDD, #666);
+  }
   &.margin-top, &.even {
     margin-top: $-l;
   }
@@ -270,13 +280,9 @@ ul, ol {
   }
 }
 ul {
-  padding-left: $-m * 1.3;
-  padding-right: $-m * 1.3;
   list-style: disc;
   ul {
     list-style: circle;
-    margin-top: 0;
-    margin-bottom: 0;
   }
   label {
     margin: 0;
@@ -285,23 +291,33 @@ ul {
 
 ol {
   list-style: decimal;
-  padding-left: $-m * 2;
-  padding-right: $-m * 2;
+}
+
+ol, ul {
+  padding-left: $-m * 2.0;
+  padding-right: $-m * 2.0;
+}
+
+li > ol, li > ul {
+  margin-top: 0;
+  margin-bottom: 0;
+  margin-block-end: 0;
+  margin-block-start: 0;
+  padding-block-end: 0;
+  padding-block-start: 0;
+  padding-left: $-m * 1.2;
+  padding-right: $-m * 1.2;
 }
 
 li.checkbox-item, li.task-list-item {
   list-style: none;
-  margin-left: - ($-m * 1.3);
+  margin-left: -($-m * 1.2);
   input[type="checkbox"] {
     margin-right: $-xs;
   }
-}
-
-li > ol, li > ul {
-  margin-block-end: 0px;
-  margin-block-start: 0px;
-  padding-block-end: 0px;
-  padding-block-start: 0px;
+  li.checkbox-item, li.task-list-item {
+    margin-left: $-xs;
+  }
 }
 
 /*
index b8682ed05f69b030e6f8fbef343cf3b995d39f3d..1a8b34c5b9af119865929d1eebde78c8180d2a99 100644 (file)
@@ -1,3 +1,4 @@
+@use "sass:math";
 @import "variables";
 @import "mixins";
 @import "html";
index 5cbd7f9d5e226b9c4710acaa3b5012862ea77c63..2c51bd75c69027122b0a1f395906170eebb4fa63 100644 (file)
@@ -1,3 +1,4 @@
+@use "sass:math";
 @import "variables";
 
 header {
index 743db9888f771b69bc42d429cbfb8d81e88e1031..582bf7c7569a7faa4a55b7fc1d159e31cd490db6 100644 (file)
@@ -1,3 +1,5 @@
+@use "sass:math";
+
 @import "reset";
 @import "variables";
 @import "mixins";
@@ -109,8 +111,8 @@ $btt-size: 40px;
   color: #FFF;
   fill: #FFF;
   svg {
-    width: $btt-size / 1.5;
-    height: $btt-size / 1.5;
+    width: math.div($btt-size, 1.5);
+    height: math.div($btt-size, 1.5);
     margin-inline-end: 4px;
   }
   width: $btt-size;
@@ -134,6 +136,23 @@ $btt-size: 40px;
   }
 }
 
+.skip-to-content-link {
+  position: fixed;
+  top: -$-xxl;
+  left: 0;
+  background-color: #FFF;
+  z-index: 15;
+  border-radius: 0 4px 4px 0;
+  display: block;
+  box-shadow: $bs-dark;
+  font-weight: bold;
+  &:focus {
+    top: $-xl;
+    outline-offset: -10px;
+    outline: 2px dotted var(--color-primary);
+  }
+}
+
 .contained-search-box {
   display: flex;
   height: 38px;
index d9c3d659513507c952e3aaecac81d6f7d6c6df06..5bec265e82b9d4704839655f18a3f9db365c76fd 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
             <div style="overflow: auto;">
 
                 <section code-highlighter class="card content-wrap auto-height">
-                    <h1 class="list-heading text-capitals mb-l">Getting Started</h1>
-
-                    <h5 id="authentication" class="text-mono mb-m">Authentication</h5>
-                    <p>
-                        To access the API a user has to have the <em>"Access System API"</em> permission enabled on one of their assigned roles.
-                        Permissions to content accessed via the API is limited by the roles & permissions assigned to the user that's used to access the API.
-                    </p>
-                    <p>Authentication to use the API is primarily done using API Tokens. Once the <em>"Access System API"</em> permission has been assigned to a user, a "API Tokens" section should be visible when editing their user profile. Choose "Create Token" and enter an appropriate name and expiry date, relevant for your API usage then press "Save". A "Token ID" and "Token Secret" will be immediately displayed. These values should be used as a header in API HTTP requests in the following format:</p>
-                    <pre><code class="language-css">Authorization: Token &lt;token_id&gt;:&lt;token_secret&gt;</code></pre>
-                    <p>Here's an example of an authorized cURL request to list books in the system:</p>
-                    <pre><code class="language-shell">curl --request GET \
-  --url https://example.com/api/books \
-  --header 'Authorization: Token C6mdvEQTGnebsmVn3sFNeeuelGEBjyQp:NOvD3VlzuSVuBPNaf1xWHmy7nIRlaj22'</code></pre>
-                    <p>If already logged into the system within the browser, via a user account with permission to access the API, the system will also accept an existing session meaning you can browse API endpoints directly in the browser or use the browser devtools to play with the API.</p>
-
-                    <hr>
-
-                    <h5 id="request-format" class="text-mono mb-m">Request Format</h5>
-                    <p>The API is primarily design to be interfaced using JSON so the majority of API endpoints, that accept data, will read JSON request data although <code>application/x-www-form-urlencoded</code> request data is also accepted. Endpoints that receive file data will need data sent in a <code>multipart/form-data</code> format although this will be highlighted in the documentation for such endpoints.</p>
-                    <p>For endpoints in this documentation that accept data, a "Body Parameters" table will be available showing the parameters that will accepted in the request. Any rules for the values of such parameters, such as the data-type or if they're required, will be shown alongside the parameter name.</p>
-
-                    <hr>
-
-                    <h5 id="listing-endpoints" class="text-mono mb-m">Listing Endpoints</h5>
-                    <p>Some endpoints will return a list of data models. These endpoints will return an array of the model data under a <code>data</code> property along with a numeric <code>total</code> property to indicate the total number of records found for the query within the system. Here's an example of a listing response:</p>
-                    <pre><code class="language-json">{
-  "data": [
-    {
-      "id": 1,
-      "name": "BookStack User Guide",
-      "slug": "bookstack-user-guide",
-      "description": "This is a general guide on using BookStack on a day-to-day basis.",
-      "created_at": "2019-05-05 21:48:46",
-      "updated_at": "2019-12-11 20:57:31",
-      "created_by": 1,
-      "updated_by": 1,
-      "image_id": 3
-    }
-  ],
-  "total": 16
-}</code></pre>
-                    <p>
-                        There are a number of standard URL parameters that can be supplied to manipulate and page through the results returned from a listing endpoint:
-                    </p>
-                    <table class="table">
-                        <tr>
-                            <th>Parameter</th>
-                            <th>Details</th>
-                            <th width="30%">Examples</th>
-                        </tr>
-                        <tr>
-                            <td>count</td>
-                            <td>
-                                Specify how many records will be returned in the response. <br>
-                                (Default: {{ config('api.default_item_count') }}, Max: {{ config('api.max_item_count') }})
-                            </td>
-                            <td>Limit the count to 50<br><code>?count=50</code></td>
-                        </tr>
-                        <tr>
-                            <td>offset</td>
-                            <td>
-                                Specify how many records to skip over in the response. <br>
-                                (Default: 0)
-                            </td>
-                            <td>Skip over the first 100 records<br><code>?offset=100</code></td>
-                        </tr>
-                        <tr>
-                            <td>sort</td>
-                            <td>
-                                Specify what field is used to sort the data and the direction of the sort (Ascending or Descending).<br>
-                                Value is the name of a field, A <code>+</code> or <code>-</code> prefix dictates ordering. <br>
-                                Direction defaults to ascending. <br>
-                                Can use most fields shown in the response.
-                            </td>
-                            <td>
-                                Sort by name ascending<br><code>?sort=+name</code> <br> <br>
-                                Sort by "Created At" date descending<br><code>?sort=-created_at</code>
-                            </td>
-                        </tr>
-                        <tr>
-                            <td>filter[&lt;field&gt;]</td>
-                            <td>
-                                Specify a filter to be applied to the query. Can use most fields shown in the response. <br>
-                                By default a filter will apply a "where equals" query but the below operations are available using the format filter[&lt;field&gt;:&lt;operation&gt;] <br>
-                                <table>
-                                    <tr>
-                                        <td>eq</td>
-                                        <td>Where <code>&lt;field&gt;</code> equals the filter value.</td>
-                                    </tr>
-                                    <tr>
-                                        <td>ne</td>
-                                        <td>Where <code>&lt;field&gt;</code> does not equal the filter value.</td>
-                                    </tr>
-                                    <tr>
-                                        <td>gt</td>
-                                        <td>Where <code>&lt;field&gt;</code> is greater than the filter value.</td>
-                                    </tr>
-                                    <tr>
-                                        <td>lt</td>
-                                        <td>Where <code>&lt;field&gt;</code> is less than the filter value.</td>
-                                    </tr>
-                                    <tr>
-                                        <td>gte</td>
-                                        <td>Where <code>&lt;field&gt;</code> is greater than or equal to the filter value.</td>
-                                    </tr>
-                                    <tr>
-                                        <td>lte</td>
-                                        <td>Where <code>&lt;field&gt;</code> is less than or equal to the filter value.</td>
-                                    </tr>
-                                    <tr>
-                                        <td>like</td>
-                                        <td>
-                                            Where <code>&lt;field&gt;</code> is "like" the filter value. <br>
-                                            <code>%</code> symbols can be used as wildcards.
-                                        </td>
-                                    </tr>
-                                </table>
-                            </td>
-                            <td>
-                                Filter where id is 5: <br><code>?filter[id]=5</code><br><br>
-                                Filter where id is not 5: <br><code>?filter[id:ne]=5</code><br><br>
-                                Filter where name contains "cat": <br><code>?filter[name:like]=%cat%</code><br><br>
-                                Filter where created after 2020-01-01: <br><code>?filter[created_at:gt]=2020-01-01</code>
-                            </td>
-                        </tr>
-                    </table>
-
-                    <hr>
-
-                    <h5 id="error-handling" class="text-mono mb-m">Error Handling</h5>
-                    <p>
-                        Successful responses will return a 200 or 204 HTTP response code. Errors will return a 4xx or a 5xx HTTP response code depending on the type of error. Errors follow a standard format as shown below. The message provided may be translated depending on the configured language of the system in addition to the API users' language preference. The code provided in the JSON response will match the HTTP response code.
-                    </p>
-
-                    <pre><code class="language-json">{
-       "error": {
-               "code": 401,
-               "message": "No authorization token found on the request"
-       }
-}
-</code></pre>
-
+                    @include('api-docs.parts.getting-started')
                 </section>
 
                 @foreach($docs as $model => $endpoints)
                         <h1 class="list-heading text-capitals">{{ $model }}</h1>
 
                         @foreach($endpoints as $endpoint)
-                            <h6 class="text-uppercase text-muted float right">{{ $endpoint['controller_method_kebab'] }}</h6>
-                            <h5 id="{{ $endpoint['name'] }}" class="text-mono mb-m">
-                                <span class="api-method" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
-                                @if($endpoint['controller_method_kebab'] === 'list')
-                                    <a style="color: inherit;" target="_blank" href="{{ url($endpoint['uri']) }}">{{ url($endpoint['uri']) }}</a>
-                                @else
-                                    {{ url($endpoint['uri']) }}
-                                @endif
-                            </h5>
-                            <p class="mb-m">{{ $endpoint['description'] ?? '' }}</p>
-                            @if($endpoint['body_params'] ?? false)
-                                <details class="mb-m">
-                                    <summary class="text-muted">Body Parameters</summary>
-                                    <table class="table">
-                                        <tr>
-                                            <th>Param Name</th>
-                                            <th>Value Rules</th>
-                                        </tr>
-                                        @foreach($endpoint['body_params'] as $paramName => $rules)
-                                        <tr>
-                                            <td>{{ $paramName }}</td>
-                                            <td>
-                                                @foreach($rules as $rule)
-                                                    <code class="mr-xs">{{ $rule }}</code>
-                                                @endforeach
-                                            </td>
-                                        </tr>
-                                        @endforeach
-                                    </table>
-                                </details>
-                            @endif
-                            @if($endpoint['example_request'] ?? false)
-                                <details details-highlighter class="mb-m">
-                                    <summary class="text-muted">Example Request</summary>
-                                    <pre><code class="language-json">{{ $endpoint['example_request'] }}</code></pre>
-                                </details>
-                            @endif
-                            @if($endpoint['example_response'] ?? false)
-                                <details details-highlighter class="mb-m">
-                                    <summary class="text-muted">Example Response</summary>
-                                    <pre><code class="language-json">{{ $endpoint['example_response'] }}</code></pre>
-                                </details>
-                            @endif
-                            @if(!$loop->last)
-                            <hr>
-                            @endif
+                            @include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop])
                         @endforeach
                     </section>
                 @endforeach
diff --git a/resources/views/api-docs/parts/endpoint.blade.php b/resources/views/api-docs/parts/endpoint.blade.php
new file mode 100644 (file)
index 0000000..6e3d936
--- /dev/null
@@ -0,0 +1,52 @@
+<h6 class="text-uppercase text-muted float right">{{ $endpoint['controller_method_kebab'] }}</h6>
+
+<h5 id="{{ $endpoint['name'] }}" class="text-mono mb-m">
+    <span class="api-method" data-method="{{ $endpoint['method'] }}">{{ $endpoint['method'] }}</span>
+    @if($endpoint['controller_method_kebab'] === 'list')
+        <a style="color: inherit;" target="_blank" rel="noopener" href="{{ url($endpoint['uri']) }}">{{ url($endpoint['uri']) }}</a>
+    @else
+        {{ url($endpoint['uri']) }}
+    @endif
+</h5>
+
+<p class="mb-m">{{ $endpoint['description'] ?? '' }}</p>
+
+@if($endpoint['body_params'] ?? false)
+    <details class="mb-m">
+        <summary class="text-muted">{{ $endpoint['method'] === 'GET' ? 'Query' : 'Body'  }} Parameters</summary>
+        <table class="table">
+            <tr>
+                <th>Param Name</th>
+                <th>Value Rules</th>
+            </tr>
+            @foreach($endpoint['body_params'] as $paramName => $rules)
+                <tr>
+                    <td>{{ $paramName }}</td>
+                    <td>
+                        @foreach($rules as $rule)
+                            <code class="mr-xs">{{ $rule }}</code>
+                        @endforeach
+                    </td>
+                </tr>
+            @endforeach
+        </table>
+    </details>
+@endif
+
+@if($endpoint['example_request'] ?? false)
+    <details details-highlighter class="mb-m">
+        <summary class="text-muted">Example Request</summary>
+        <pre><code class="language-json">{{ $endpoint['example_request'] }}</code></pre>
+    </details>
+@endif
+
+@if($endpoint['example_response'] ?? false)
+    <details details-highlighter class="mb-m">
+        <summary class="text-muted">Example Response</summary>
+        <pre><code class="language-json">{{ $endpoint['example_response'] }}</code></pre>
+    </details>
+@endif
+
+@if(!$loop->last)
+    <hr>
+@endif
\ No newline at end of file
diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php
new file mode 100644 (file)
index 0000000..3bcf29d
--- /dev/null
@@ -0,0 +1,163 @@
+<h1 class="list-heading text-capitals mb-l">Getting Started</h1>
+
+<p class="mb-none">
+    This documentation covers use of the REST API. <br>
+    Some alternative options for extension and customization can be found below:
+</p>
+
+<ul>
+    <li>
+        <a href="{{ url('/settings/webhooks') }}" target="_blank" rel="noopener noreferrer">Webhooks</a> -
+        HTTP POST calls upon events occurring in BookStack.
+    </li>
+    <li>
+        <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
+        Methods to override views, translations and icons within BookStack.
+    </li>
+    <li>
+        <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
+        Methods to extend back-end functionality within BookStack.
+    </li>
+</ul>
+
+<hr>
+
+<h5 id="authentication" class="text-mono mb-m">Authentication</h5>
+<p>
+    To access the API a user has to have the <em>"Access System API"</em> permission enabled on one of their assigned roles.
+    Permissions to content accessed via the API is limited by the roles & permissions assigned to the user that's used to access the API.
+</p>
+<p>Authentication to use the API is primarily done using API Tokens. Once the <em>"Access System API"</em> permission has been assigned to a user, a "API Tokens" section should be visible when editing their user profile. Choose "Create Token" and enter an appropriate name and expiry date, relevant for your API usage then press "Save". A "Token ID" and "Token Secret" will be immediately displayed. These values should be used as a header in API HTTP requests in the following format:</p>
+<pre><code class="language-css">Authorization: Token &lt;token_id&gt;:&lt;token_secret&gt;</code></pre>
+<p>Here's an example of an authorized cURL request to list books in the system:</p>
+<pre><code class="language-shell">curl --request GET \
+  --url https://example.com/api/books \
+  --header 'Authorization: Token C6mdvEQTGnebsmVn3sFNeeuelGEBjyQp:NOvD3VlzuSVuBPNaf1xWHmy7nIRlaj22'</code></pre>
+<p>If already logged into the system within the browser, via a user account with permission to access the API, the system will also accept an existing session meaning you can browse API endpoints directly in the browser or use the browser devtools to play with the API.</p>
+
+<hr>
+
+<h5 id="request-format" class="text-mono mb-m">Request Format</h5>
+<p>The API is primarily design to be interfaced using JSON so the majority of API endpoints, that accept data, will read JSON request data although <code>application/x-www-form-urlencoded</code> request data is also accepted. Endpoints that receive file data will need data sent in a <code>multipart/form-data</code> format although this will be highlighted in the documentation for such endpoints.</p>
+<p>For endpoints in this documentation that accept data, a "Body Parameters" table will be available showing the parameters that will accepted in the request. Any rules for the values of such parameters, such as the data-type or if they're required, will be shown alongside the parameter name.</p>
+
+<hr>
+
+<h5 id="listing-endpoints" class="text-mono mb-m">Listing Endpoints</h5>
+<p>Some endpoints will return a list of data models. These endpoints will return an array of the model data under a <code>data</code> property along with a numeric <code>total</code> property to indicate the total number of records found for the query within the system. Here's an example of a listing response:</p>
+<pre><code class="language-json">{
+  "data": [
+    {
+      "id": 1,
+      "name": "BookStack User Guide",
+      "slug": "bookstack-user-guide",
+      "description": "This is a general guide on using BookStack on a day-to-day basis.",
+      "created_at": "2019-05-05 21:48:46",
+      "updated_at": "2019-12-11 20:57:31",
+      "created_by": 1,
+      "updated_by": 1,
+      "image_id": 3
+    }
+  ],
+  "total": 16
+}</code></pre>
+<p>
+    There are a number of standard URL parameters that can be supplied to manipulate and page through the results returned from a listing endpoint:
+</p>
+<table class="table">
+    <tr>
+        <th width="110">Parameter</th>
+        <th>Details</th>
+        <th width="30%">Examples</th>
+    </tr>
+    <tr>
+        <td>count</td>
+        <td>
+            Specify how many records will be returned in the response. <br>
+            (Default: {{ config('api.default_item_count') }}, Max: {{ config('api.max_item_count') }})
+        </td>
+        <td>Limit the count to 50<br><code>?count=50</code></td>
+    </tr>
+    <tr>
+        <td>offset</td>
+        <td>
+            Specify how many records to skip over in the response. <br>
+            (Default: 0)
+        </td>
+        <td>Skip over the first 100 records<br><code>?offset=100</code></td>
+    </tr>
+    <tr>
+        <td>sort</td>
+        <td>
+            Specify what field is used to sort the data and the direction of the sort (Ascending or Descending).<br>
+            Value is the name of a field, A <code>+</code> or <code>-</code> prefix dictates ordering. <br>
+            Direction defaults to ascending. <br>
+            Can use most fields shown in the response.
+        </td>
+        <td>
+            Sort by name ascending<br><code>?sort=+name</code> <br> <br>
+            Sort by "Created At" date descending<br><code>?sort=-created_at</code>
+        </td>
+    </tr>
+    <tr>
+        <td>filter[&lt;field&gt;]</td>
+        <td>
+            Specify a filter to be applied to the query. Can use most fields shown in the response. <br>
+            By default a filter will apply a "where equals" query but the below operations are available using the format filter[&lt;field&gt;:&lt;operation&gt;] <br>
+            <table>
+                <tr>
+                    <td>eq</td>
+                    <td>Where <code>&lt;field&gt;</code> equals the filter value.</td>
+                </tr>
+                <tr>
+                    <td>ne</td>
+                    <td>Where <code>&lt;field&gt;</code> does not equal the filter value.</td>
+                </tr>
+                <tr>
+                    <td>gt</td>
+                    <td>Where <code>&lt;field&gt;</code> is greater than the filter value.</td>
+                </tr>
+                <tr>
+                    <td>lt</td>
+                    <td>Where <code>&lt;field&gt;</code> is less than the filter value.</td>
+                </tr>
+                <tr>
+                    <td>gte</td>
+                    <td>Where <code>&lt;field&gt;</code> is greater than or equal to the filter value.</td>
+                </tr>
+                <tr>
+                    <td>lte</td>
+                    <td>Where <code>&lt;field&gt;</code> is less than or equal to the filter value.</td>
+                </tr>
+                <tr>
+                    <td>like</td>
+                    <td>
+                        Where <code>&lt;field&gt;</code> is "like" the filter value. <br>
+                        <code>%</code> symbols can be used as wildcards.
+                    </td>
+                </tr>
+            </table>
+        </td>
+        <td>
+            Filter where id is 5: <br><code>?filter[id]=5</code><br><br>
+            Filter where id is not 5: <br><code>?filter[id:ne]=5</code><br><br>
+            Filter where name contains "cat": <br><code>?filter[name:like]=%cat%</code><br><br>
+            Filter where created after 2020-01-01: <br><code>?filter[created_at:gt]=2020-01-01</code>
+        </td>
+    </tr>
+</table>
+
+<hr>
+
+<h5 id="error-handling" class="text-mono mb-m">Error Handling</h5>
+<p>
+    Successful responses will return a 200 or 204 HTTP response code. Errors will return a 4xx or a 5xx HTTP response code depending on the type of error. Errors follow a standard format as shown below. The message provided may be translated depending on the configured language of the system in addition to the API users' language preference. The code provided in the JSON response will match the HTTP response code.
+</p>
+
+<pre><code class="language-json">{
+       "error": {
+               "code": 401,
+               "message": "No authorization token found on the request"
+       }
+}
+</code></pre>
\ No newline at end of file
index 8c9be8290025932277c6f1c1436e67faa7479381..f0a1354ea1e513adda8ad44b1c8e03ed63c0a2a2 100644 (file)
@@ -1,8 +1,10 @@
-@foreach($attachments as $attachment)
-    <div class="attachment icon-list">
-        <a class="icon-list-item py-xs" href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif>
-            <span class="icon">@icon($attachment->external ? 'export' : 'file')</span>
-            <span>{{ $attachment->name }}</span>
-        </a>
-    </div>
-@endforeach
\ No newline at end of file
+<div component="attachments-list">
+    @foreach($attachments as $attachment)
+        <div class="attachment icon-list">
+            <a class="icon-list-item py-xs attachment-{{ $attachment->external ? 'link' : 'file' }}" href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif>
+                <span class="icon">@icon($attachment->external ? 'export' : 'file')</span>
+                <span>{{ $attachment->name }}</span>
+            </a>
+        </div>
+    @endforeach
+</div>
\ No newline at end of file
index ee86dc24006510ee905aadc16aef601aeb2af259..15837448ac0be451ee46c1d2600ee63e41fa4e89 100644 (file)
@@ -22,7 +22,7 @@
             <button refs="tabs@toggleLink" type="button" class="tab-item {{ $attachment->external ? 'selected' : '' }}">{{ trans('entities.attachments_set_link') }}</button>
         </div>
         <div refs="tabs@contentFile" class="mb-m {{ $attachment->external ? 'hidden' : '' }}">
-            @include('components.dropzone', [
+            @include('form.dropzone', [
                 'placeholder' => trans('entities.attachments_edit_drop_upload'),
                 'url' =>  url('/attachments/upload/' . $attachment->id),
                 'successMessage' => trans('entities.attachments_file_updated'),
index 313faa5755f34e9aebda614f47f563a762911956..b48fde9c01d90c0efe6341fcf88c7ec5d1b5d8a4 100644 (file)
@@ -7,7 +7,7 @@
              class="card drag-card">
             <div class="handle">@icon('grip')</div>
             <div class="py-s">
-                <a href="{{ $attachment->getUrl() }}" target="_blank">{{ $attachment->name }}</a>
+                <a href="{{ $attachment->getUrl() }}" target="_blank" rel="noopener">{{ $attachment->name }}</a>
             </div>
             <div class="flex-fill justify-flex-end">
                 <button component="event-emit-select"
index 4628f7495650def200565f8babea00a06b96b464..024cb583c522e8147fca04626f1a67ba09e4e31a 100644 (file)
@@ -18,7 +18,7 @@
                     @include('attachments.manager-list', ['attachments' => $page->attachments->all()])
                 </div>
                 <div refs="tabs@contentUpload" class="hidden">
-                    @include('components.dropzone', [
+                    @include('form.dropzone', [
                         'placeholder' => trans('entities.attachments_dropzone'),
                         'url' =>  url('/attachments/upload?uploaded_to=' . $page->id),
                         'successMessage' => trans('entities.attachments_file_uploaded'),
index fbe62f21e9fabdbe036f0d635dde3913d04291e4..c29ed57067bd1f1b1c47f5ddc1bc9de4a3b980d7 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
index 4212c1964c8c0c478aa8c7f3c51da1607f13fe94..de99bb3f29feeb55babaa3f13020c474978f4a1f 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
@@ -9,7 +9,7 @@
         <div class="card content-wrap auto-height">
             <h1 class="list-heading">{{ Str::title(trans('auth.log_in')) }}</h1>
 
-            @include('auth.forms.login.' . $authMethod)
+            @include('auth.parts.login-form-' . $authMethod)
 
             @if(count($socialDrivers) > 0)
                 <hr class="my-l">
diff --git a/resources/views/auth/parts/login-form-oidc.blade.php b/resources/views/auth/parts/login-form-oidc.blade.php
new file mode 100644 (file)
index 0000000..e5e1b70
--- /dev/null
@@ -0,0 +1,11 @@
+<form action="{{ url('/oidc/login') }}" method="POST" id="login-form" class="mt-l">
+    {!! csrf_field() !!}
+
+    <div>
+        <button id="oidc-login" class="button outline svg">
+            @icon('oidc')
+            <span>{{ trans('auth.log_in_with', ['socialDriver' => config('oidc.name')]) }}</span>
+        </button>
+    </div>
+
+</form>
similarity index 79%
rename from resources/views/auth/forms/login/saml2.blade.php
rename to resources/views/auth/parts/login-form-saml2.blade.php
index 7d6595894fa5222c974e670415bd7dceddc04b3f..1afd2d9bb6d6540b659a3aa9bb32ae48d1b76127 100644 (file)
@@ -2,7 +2,7 @@
     {!! csrf_field() !!}
 
     <div>
-        <button id="saml-login" class="button outline block svg">
+        <button id="saml-login" class="button outline svg">
             @icon('saml2')
             <span>{{ trans('auth.log_in_with', ['socialDriver' => config('saml2.name')]) }}</span>
         </button>
similarity index 95%
rename from resources/views/auth/forms/login/standard.blade.php
rename to resources/views/auth/parts/login-form-standard.blade.php
index 87603e2cb6dc8fd88a9c41bbb9b3ad0d5d35b992..71989dc2f23195353096e8162f2db7b26992931f 100644 (file)
@@ -18,7 +18,7 @@
 
     <div class="grid half collapse-xs gap-xl v-center">
         <div class="text-left ml-xxs">
-            @include('components.custom-checkbox', [
+            @include('form.custom-checkbox', [
                 'name' => 'remember',
                 'checked' => false,
                 'value' => 'on',
index 8273ed2356bf931f7fbe142fd6e4ad1e4841baec..d42b9e3c66d68a09bd19121363aca6900cb9f9e0 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
     <div class="container very-small mt-xl">
index 930544cde40e870c788c1793afeb70661bc010a2..de332f9e177b36ef16b17022b38bc0415d5eabf2 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
index 8fbf8abbbd7beae0c6d70af0a87d3183c4a57492..0de877a53f49509799b9f2695791cd29b7e80c90 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
index d3483c6e610f5a3d370a8255b053c58d8b665b02..91ec0b621f12f2190edd676c31e3ff1310db7dc2 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
     <div class="container very-small">
index 85473685b96207e108d07d3093a2c23fa4f43bcd..2f780b8a31f21eaa097966fdc7b1cb4655b6baef 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
@@ -17,8 +17,8 @@
                 {!! csrf_field() !!}
                 <div class="form-group">
                     <label for="email">{{ trans('auth.email') }}</label>
-                    @if(auth()->check())
-                        @include('form.text', ['name' => 'email', 'model' => auth()->user()])
+                    @if($user)
+                        @include('form.text', ['name' => 'email', 'model' => $user])
                     @else
                         @include('form.text', ['name' => 'email'])
                     @endif
diff --git a/resources/views/books/_breadcrumbs.blade.php b/resources/views/books/_breadcrumbs.blade.php
deleted file mode 100644 (file)
index e4ecc36..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-<div class="breadcrumbs">
-    <a href="{{$book->getUrl()}}" class="text-book text-button">@icon('book'){{ $book->getShortName() }}</a>
-</div>
\ No newline at end of file
diff --git a/resources/views/books/copy.blade.php b/resources/views/books/copy.blade.php
new file mode 100644 (file)
index 0000000..293397a
--- /dev/null
@@ -0,0 +1,40 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="my-s">
+            @include('entities.breadcrumbs', ['crumbs' => [
+                $book,
+                $book->getUrl('/copy') => [
+                    'text' => trans('entities.books_copy'),
+                    'icon' => 'copy',
+                ]
+            ]])
+        </div>
+
+        <div class="card content-wrap auto-height">
+
+            <h1 class="list-heading">{{ trans('entities.books_copy') }}</h1>
+
+            <form action="{{ $book->getUrl('/copy') }}" method="POST">
+                {!! csrf_field() !!}
+
+                <div class="form-group title-input">
+                    <label for="name">{{ trans('common.name') }}</label>
+                    @include('form.text', ['name' => 'name'])
+                </div>
+
+                @include('entities.copy-considerations')
+
+                <div class="form-group text-right">
+                    <a href="{{ $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('entities.books_copy') }}</button>
+                </div>
+            </form>
+
+        </div>
+    </div>
+
+@stop
index db3e90e51913a8d6870417a1687984107a050ec7..eead4191c345260382c36fc664c2bfc91eaaf5f1 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
         <div class="my-s">
             @if (isset($bookshelf))
-                @include('partials.breadcrumbs', ['crumbs' => [
+                @include('entities.breadcrumbs', ['crumbs' => [
                     $bookshelf,
                     $bookshelf->getUrl('/create-book') => [
                         'text' => trans('entities.books_create'),
@@ -12,7 +12,7 @@
                     ]
                 ]])
             @else
-                @include('partials.breadcrumbs', ['crumbs' => [
+                @include('entities.breadcrumbs', ['crumbs' => [
                     '/books' => [
                         'text' => trans('entities.books'),
                         'icon' => 'book'
@@ -28,7 +28,7 @@
         <main class="content-wrap card">
             <h1 class="list-heading">{{ trans('entities.books_create') }}</h1>
             <form action="{{ isset($bookshelf) ? $bookshelf->getUrl('/create-book') : url('/books') }}" method="POST" enctype="multipart/form-data">
-                @include('books.form', ['returnLocation' => isset($bookshelf) ? $bookshelf->getUrl() : url('/books')])
+                @include('books.parts.form', ['returnLocation' => isset($bookshelf) ? $bookshelf->getUrl() : url('/books')])
             </form>
         </main>
     </div>
index be3f742cba5d13f5bc45c01864d3122ba4043283..b0f3590c17cd73b27f0f4f8f48c291747109ef8e 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $book,
                 $book->getUrl('/delete') => [
                     'text' => trans('entities.books_delete'),
index ac11b58e201df206b3a8cc4b58d8422a16606877..4039771213d49e57d9d6e9924c00b67ebee25c8e 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $book,
                 $book->getUrl('/edit') => [
                     'text' => trans('entities.books_edit'),
@@ -18,7 +18,7 @@
             <h1 class="list-heading">{{ trans('entities.books_edit') }}</h1>
             <form action="{{ $book->getUrl() }}" method="POST" enctype="multipart/form-data">
                 <input type="hidden" name="_method" value="PUT">
-                @include('books.form', ['model' => $book, 'returnLocation' => $book->getUrl()])
+                @include('books.parts.form', ['model' => $book, 'returnLocation' => $book->getUrl()])
             </form>
         </main>
     </div>
index 9cd5618a53f3be19fe609fefdeadb3e6e1fed4f0..0b6b4a58c19108e6bcf341ea46b8c37df633610c 100644 (file)
@@ -1,4 +1,4 @@
-@extends('export-layout')
+@extends('layouts.export')
 
 @section('title', $book->name)
 
index 81fb66cfcd18148a796cd478ed53b43110a4817c..6573bbe6a87adef2838aa2808d863fd08b96d7d6 100644 (file)
@@ -1,21 +1,21 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('body')
-    @include('books.list', ['books' => $books, 'view' => $view])
+    @include('books.parts.list', ['books' => $books, 'view' => $view])
 @stop
 
 @section('left')
     @if($recents)
         <div id="recents" class="mb-xl">
             <h5>{{ trans('entities.recently_viewed') }}</h5>
-            @include('partials.entity-list', ['entities' => $recents, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $recents, 'style' => 'compact'])
         </div>
     @endif
 
     <div id="popular" class="mb-xl">
         <h5>{{ trans('entities.books_popular') }}</h5>
         @if(count($popular) > 0)
-            @include('partials.entity-list', ['entities' => $popular, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $popular, 'style' => 'compact'])
         @else
             <div class="body text-muted">{{ trans('entities.books_popular_empty') }}</div>
         @endif
@@ -24,7 +24,7 @@
     <div id="new" class="mb-xl">
         <h5>{{ trans('entities.books_new') }}</h5>
         @if(count($popular) > 0)
-            @include('partials.entity-list', ['entities' => $new, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $new, 'style' => 'compact'])
         @else
             <div class="body text-muted">{{ trans('entities.books_new_empty') }}</div>
         @endif
                 </a>
             @endif
 
-            @include('partials.view-toggle', ['view' => $view, 'type' => 'books'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
+
+            <a href="{{ url('/tags') }}" class="icon-list-item">
+                <span>@icon('tag')</span>
+                <span>{{ trans('entities.tags_view_tags') }}</span>
+            </a>
         </div>
     </div>
 
similarity index 89%
rename from resources/views/books/form.blade.php
rename to resources/views/books/parts/form.blade.php
index 840d0604c8e0c22185d6e33ea43eac02e880ff88..bb87089b272ac3a30e62f5846c7611c67ca8bb4a 100644 (file)
@@ -2,7 +2,7 @@
 {{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
-    @include('form.text', ['name' => 'name'])
+    @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
@@ -17,7 +17,7 @@
     <div class="collapse-content" collapsible-content>
         <p class="small">{{ trans('common.cover_image_description') }}</p>
 
-        @include('components.image-picker', [
+        @include('form.image-picker', [
             'defaultImage' => url('/book_default_cover.png'),
             'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') ,
             'name' => 'image',
@@ -31,7 +31,7 @@
         <label for="tag-manager">{{ trans('entities.book_tags') }}</label>
     </button>
     <div class="collapse-content" collapsible-content>
-        @include('components.tag-manager', ['entity' => $book ?? null])
+        @include('entities.tag-manager', ['entity' => $book ?? null])
     </div>
 </div>
 
similarity index 84%
rename from resources/views/books/list.blade.php
rename to resources/views/books/parts/list.blade.php
index 52cd935d1182a7babb4b2926e56f8586ecf08cf8..30b0766135ccff81c87fb2d3fa7c1a5ba9d2de4a 100644 (file)
@@ -3,7 +3,7 @@
         <h1 class="list-heading">{{ trans('entities.books') }}</h1>
         <div class="text-m-right my-m">
 
-            @include('partials.sort', ['options' => [
+            @include('entities.sort', ['options' => [
                 'name' => trans('common.sort_name'),
                 'created_at' => trans('common.sort_created_at'),
                 'updated_at' => trans('common.sort_updated_at'),
         @if($view === 'list')
             <div class="entity-list">
                 @foreach($books as $book)
-                    @include('books.list-item', ['book' => $book])
+                    @include('books.parts.list-item', ['book' => $book])
                 @endforeach
             </div>
         @else
              <div class="grid third">
                 @foreach($books as $key => $book)
-                    @include('partials.entity-grid-item', ['entity' => $book])
+                    @include('entities.grid-item', ['entity' => $book])
                 @endforeach
              </div>
         @endif
index b387ed6c7c94b082800b1a72979c61668308a2f7..d72042d42f5efe3d1ec4d4abfb97f0aa7f74e3d9 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $book,
                 $book->getUrl('/permissions') => [
                     'text' => trans('entities.books_permissions'),
index def198bddac2450b34958534cd4572da01ee0ba5..5263bc8101007d6196a1f9bff34dc462d490e7e0 100644 (file)
@@ -1,4 +1,4 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('container-attrs')
     component="entity-search"
@@ -6,10 +6,17 @@
     option:entity-search:entity-type="book"
 @stop
 
+@push('social-meta')
+    <meta property="og:description" content="{{ Str::limit($book->description, 100, '...') }}">
+    @if($book->cover)
+        <meta property="og:image" content="{{ $book->getBookCover() }}">
+    @endif
+@endpush
+
 @section('body')
 
     <div class="mb-s">
-        @include('partials.breadcrumbs', ['crumbs' => [
+        @include('entities.breadcrumbs', ['crumbs' => [
             $book,
         ]])
     </div>
@@ -22,9 +29,9 @@
                 <div class="entity-list book-contents">
                     @foreach($bookChildren as $childElement)
                         @if($childElement->isA('chapter'))
-                            @include('chapters.list-item', ['chapter' => $childElement])
+                            @include('chapters.parts.list-item', ['chapter' => $childElement])
                         @else
-                            @include('pages.list-item', ['page' => $childElement])
+                            @include('pages.parts.list-item', ['page' => $childElement])
                         @endif
                     @endforeach
                 </div>
@@ -52,7 +59,7 @@
             @endif
         </div>
 
-        @include('partials.entity-search-results')
+        @include('entities.search-results')
     </main>
 
 @stop
@@ -61,7 +68,7 @@
     <div class="mb-xl">
         <h5>{{ trans('common.details') }}</h5>
         <div class="text-small text-muted blended-links">
-            @include('partials.entity-meta', ['entity' => $book])
+            @include('entities.meta', ['entity' => $book])
             @if($book->restricted)
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $book))
                     <span>{{ trans('common.sort') }}</span>
                 </a>
             @endif
+            @if(userCan('book-create-all'))
+                <a href="{{ $book->getUrl('/copy') }}" class="icon-list-item">
+                    <span>@icon('copy')</span>
+                    <span>{{ trans('common.copy') }}</span>
+                </a>
+            @endif
             @if(userCan('restrictions-manage', $book))
                 <a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item">
                     <span>@icon('lock')</span>
 
             <hr class="primary-background">
 
-            @include('partials.entity-export-menu', ['entity' => $book])
+            @if(signedInUser())
+                @include('entities.favourite-action', ['entity' => $book])
+            @endif
+            @if(userCan('content-export'))
+                @include('entities.export-menu', ['entity' => $book])
+            @endif
         </div>
     </div>
 
 
 @section('left')
 
-    @include('partials.entity-search-form', ['label' => trans('entities.books_search_this')])
+    @include('entities.search-form', ['label' => trans('entities.books_search_this')])
 
     @if($book->tags->count() > 0)
         <div class="mb-xl">
-            @include('components.tag-list', ['entity' => $book])
+            @include('entities.tag-list', ['entity' => $book])
         </div>
     @endif
 
     @if(count($bookParentShelves) > 0)
         <div class="actions mb-xl">
             <h5>{{ trans('entities.shelves_long') }}</h5>
-            @include('partials.entity-list', ['entities' => $bookParentShelves, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $bookParentShelves, 'style' => 'compact'])
         </div>
     @endif
 
     @if(count($activity) > 0)
         <div class="mb-xl">
             <h5>{{ trans('entities.recent_activity') }}</h5>
-            @include('partials.activity-list', ['activity' => $activity])
+            @include('common.activity-list', ['activity' => $activity])
         </div>
     @endif
 @stop
index 642b88c873a1c442451494903a5db83db76c5c33..a24bd8959203c9b5f24b263aec0374d8fced88c2 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $book,
                 $book->getUrl('/sort') => [
                     'text' => trans('entities.books_sort'),
@@ -19,7 +19,7 @@
                 <div book-sort class="card content-wrap">
                     <h1 class="list-heading mb-l">{{ trans('entities.books_sort') }}</h1>
                     <div book-sort-boxes>
-                        @include('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
+                        @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren])
                     </div>
 
                     <form action="{{ $book->getUrl('/sort') }}" method="POST">
@@ -38,7 +38,7 @@
                 <main class="card content-wrap">
                     <h2 class="list-heading mb-m">{{ trans('entities.books_sort_show_other') }}</h2>
 
-                    @include('components.entity-selector', ['name' => 'books_list', 'selectorSize' => 'compact', 'entityTypes' => 'book', 'entityPermission' => 'update', 'showAdd' => true])
+                    @include('entities.selector', ['name' => 'books_list', 'selectorSize' => 'compact', 'entityTypes' => 'book', 'entityPermission' => 'update', 'showAdd' => true])
 
                 </main>
             </div>
diff --git a/resources/views/chapters/copy.blade.php b/resources/views/chapters/copy.blade.php
new file mode 100644 (file)
index 0000000..3fd5de1
--- /dev/null
@@ -0,0 +1,50 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="my-s">
+            @include('entities.breadcrumbs', ['crumbs' => [
+                $chapter->book,
+                $chapter,
+                $chapter->getUrl('/copy') => [
+                    'text' => trans('entities.chapters_copy'),
+                    'icon' => 'copy',
+                ]
+            ]])
+        </div>
+
+        <div class="card content-wrap auto-height">
+
+            <h1 class="list-heading">{{ trans('entities.chapters_copy') }}</h1>
+
+            <form action="{{ $chapter->getUrl('/copy') }}" method="POST">
+                {!! csrf_field() !!}
+
+                <div class="form-group title-input">
+                    <label for="name">{{ trans('common.name') }}</label>
+                    @include('form.text', ['name' => 'name'])
+                </div>
+
+                <div class="form-group" collapsible>
+                    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
+                        <label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label>
+                    </button>
+                    <div class="collapse-content" collapsible-content>
+                        @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create'])
+                    </div>
+                </div>
+
+                @include('entities.copy-considerations')
+
+                <div class="form-group text-right">
+                    <a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('entities.chapters_copy') }}</button>
+                </div>
+            </form>
+
+        </div>
+    </div>
+
+@stop
index c9787e6348991073a5c3e265097e0926b8179338..1216f4d277cb2afa222dfc5f82606cca763d9231 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $book,
                 $book->getUrl('create-chapter') => [
                     'text' => trans('entities.chapters_create'),
@@ -16,7 +16,7 @@
         <main class="content-wrap card">
             <h1 class="list-heading">{{ trans('entities.chapters_create') }}</h1>
             <form action="{{ $book->getUrl('/create-chapter') }}" method="POST">
-                @include('chapters.form')
+                @include('chapters.parts.form')
             </form>
         </main>
 
index 60f8c99339022535b48019d4232b3a764c831574..e0e774ddf7ce14f7319c3344c73a7c25628a6c42 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $chapter->book,
                 $chapter,
                 $chapter->getUrl('/delete') => [
index d8bb056f632ce8d172da67ebb8b2e50ad7c092cb..65c48c18d77aba368a7af0872ad273d672e764a2 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $book,
                 $chapter,
                 $chapter->getUrl('/edit') => [
@@ -19,7 +19,7 @@
             <h1 class="list-heading">{{ trans('entities.chapters_edit') }}</h1>
             <form action="{{  $chapter->getUrl() }}" method="POST">
                 <input type="hidden" name="_method" value="PUT">
-                @include('chapters.form', ['model' => $chapter])
+                @include('chapters.parts.form', ['model' => $chapter])
             </form>
         </main>
 
index 18f056f27f175570b8546be9322bc858afecbd19..61286ab170a9d4fd471f2cb22f9e9aeb8e7e2a73 100644 (file)
@@ -1,4 +1,4 @@
-@extends('export-layout')
+@extends('layouts.export')
 
 @section('title', $chapter->name)
 
index 8663dca5050e53fb0dbbc9ab6b369d720bbab59a..96d4021ee4cf247c4e2232e9fc31084a9bbfafe2 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $chapter->book,
                 $chapter,
                 $chapter->getUrl('/move') => [
@@ -23,7 +23,7 @@
                 {!! csrf_field() !!}
                 <input type="hidden" name="_method" value="PUT">
 
-                @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create'])
+                @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book', 'entityPermission' => 'chapter-create'])
 
                 <div class="form-group text-right">
                     <a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
similarity index 81%
rename from resources/views/chapters/child-menu.blade.php
rename to resources/views/chapters/parts/child-menu.blade.php
index a1358e1db4e0398cc8d339a79213df190a2ab3f5..a00f0f7e1ae341c3a6638fe091c153118e7c9baf 100644 (file)
@@ -6,7 +6,7 @@
     <ul class="sub-menu inset-list @if($isOpen) open @endif" @if($isOpen) style="display: block;" @endif role="menu">
         @foreach($bookChild->visible_pages as $childPage)
             <li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}" role="presentation">
-                @include('partials.entity-list-item-basic', ['entity' => $childPage, 'classes' => $current->matches($childPage)? 'selected' : '' ])
+                @include('entities.list-item-basic', ['entity' => $childPage, 'classes' => $current->matches($childPage)? 'selected' : '' ])
             </li>
         @endforeach
     </ul>
similarity index 86%
rename from resources/views/chapters/form.blade.php
rename to resources/views/chapters/parts/form.blade.php
index 60cfe6674f1b7eeaf7382575a24e970264698593..3908d0693fb7a3ec635be5d609dfb61f579c3d7c 100644 (file)
@@ -3,7 +3,7 @@
 
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
-    @include('form.text', ['name' => 'name'])
+    @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
@@ -16,7 +16,7 @@
         <label for="tags">{{ trans('entities.chapter_tags') }}</label>
     </button>
     <div class="collapse-content" collapsible-content>
-        @include('components.tag-manager', ['entity' => $chapter ?? null])
+        @include('entities.tag-manager', ['entity' => $chapter ?? null])
     </div>
 </div>
 
similarity index 92%
rename from resources/views/chapters/list-item.blade.php
rename to resources/views/chapters/parts/list-item.blade.php
index 9186983332eaae842d8af07aff34abbfcfd6f994..285e3489353cca255481e32241245f856527aae0 100644 (file)
@@ -18,7 +18,7 @@
                     class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->visible_pages->count()) }}</span></button>
             <div class="inset-list">
                 <div class="entity-list-item-children">
-                    @include('partials.entity-list', ['entities' => $chapter->visible_pages])
+                    @include('entities.list', ['entities' => $chapter->visible_pages])
                 </div>
             </div>
         </div>
index 48c954dc96871b0f23154772ec2ba6e79a600d80..6b4e219384a746e22841b27c60fbff24a9c48e37 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $chapter->book,
                 $chapter,
                 $chapter->getUrl('/permissions') => [
index db02ebcc4f9ae6b6d686a591a8eb2398f5d905d8..edd39eddebed863b45c55b95396adfaae6a9880a 100644 (file)
@@ -1,4 +1,4 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('container-attrs')
     component="entity-search"
@@ -6,10 +6,14 @@
     option:entity-search:entity-type="chapter"
 @stop
 
+@push('social-meta')
+    <meta property="og:description" content="{{ Str::limit($chapter->description, 100, '...') }}">
+@endpush
+
 @section('body')
 
     <div class="mb-m print-hidden">
-        @include('partials.breadcrumbs', ['crumbs' => [
+        @include('entities.breadcrumbs', ['crumbs' => [
             $chapter->book,
             $chapter,
         ]])
@@ -22,7 +26,7 @@
             @if(count($pages) > 0)
                 <div class="entity-list book-contents">
                     @foreach($pages as $page)
-                        @include('pages.list-item', ['page' => $page])
+                        @include('pages.parts.list-item', ['page' => $page])
                     @endforeach
                 </div>
             @else
             @endif
         </div>
 
-        @include('partials.entity-search-results')
+        @include('entities.search-results')
     </main>
 
+    @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
+
 @stop
 
 @section('right')
@@ -59,7 +65,7 @@
     <div class="mb-xl">
         <h5>{{ trans('common.details') }}</h5>
         <div class="blended-links text-small text-muted">
-            @include('partials.entity-meta', ['entity' => $chapter])
+            @include('entities.meta', ['entity' => $chapter])
 
             @if($book->restricted)
                 <div class="active-restriction">
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
+            @if(userCanOnAny('chapter-create'))
+                <a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
+                    <span>@icon('copy')</span>
+                    <span>{{ trans('common.copy') }}</span>
+                </a>
+            @endif
             @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
                 <a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
                     <span>@icon('folder')</span>
 
             <hr class="primary-background"/>
 
-            @include('partials.entity-export-menu', ['entity' => $chapter])
+            @if(signedInUser())
+                @include('entities.favourite-action', ['entity' => $chapter])
+            @endif
+            @if(userCan('content-export'))
+                @include('entities.export-menu', ['entity' => $chapter])
+            @endif
         </div>
     </div>
 @stop
 
 @section('left')
 
-    @include('partials.entity-search-form', ['label' => trans('entities.chapters_search_this')])
+    @include('entities.search-form', ['label' => trans('entities.chapters_search_this')])
 
     @if($chapter->tags->count() > 0)
         <div class="mb-xl">
-            @include('components.tag-list', ['entity' => $chapter])
+            @include('entities.tag-list', ['entity' => $chapter])
         </div>
     @endif
 
-    @include('partials.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
+    @include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
 @stop
 
 
index 322477ebdc4e98783c8a666043b048ae36fda804..9f4a12357156cb41b65f08f61955c9d636e7d763 100644 (file)
@@ -48,7 +48,7 @@
 
     <div comment-content class="content px-s pb-s">
         <div class="form-group loading" style="display: none;">
-            @include('partials.loading-icon', ['text' => trans('entities.comment_deleting')])
+            @include('common.loading-icon', ['text' => trans('entities.comment_deleting')])
         </div>
         {!! $comment->html  !!}
     </div>
@@ -64,7 +64,7 @@
                     <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
                 </div>
                 <div class="form-group loading" style="display: none;">
-                    @include('partials.loading-icon', ['text' => trans('entities.comment_saving')])
+                    @include('common.loading-icon', ['text' => trans('entities.comment_saving')])
                 </div>
             </form>
         </div>
index 12216b95b9bdb1f5ef33bfc3403094773b5aa73b..a5a84b004dc8e20a2fbf514487f5bfa13b548e3d 100644 (file)
@@ -24,7 +24,7 @@
                 <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
             </div>
             <div class="form-group loading" style="display: none;">
-                @include('partials.loading-icon', ['text' => trans('entities.comment_saving')])
+                @include('common.loading-icon', ['text' => trans('entities.comment_saving')])
             </div>
         </form>
     </div>
similarity index 93%
rename from resources/views/partials/activity-item.blade.php
rename to resources/views/common/activity-item.blade.php
index eebfb591a4af40713816963b5c978d5a6b3ba171..89d44b15231a37037a4fe6475f6e4b8e3672cf15 100644 (file)
@@ -24,8 +24,6 @@
         "{{ $activity->entity->name }}"
     @endif
 
-    @if($activity->extra) "{{ $activity->extra }}" @endif
-
     <br>
 
     <span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span>
similarity index 76%
rename from resources/views/partials/activity-list.blade.php
rename to resources/views/common/activity-list.blade.php
index 397a69d38cb8f3b471cad9acfaaa958faa0ec0ea..90272b21c6921ab8672bfa788f52b2aa09cd0641 100644 (file)
@@ -3,7 +3,7 @@
     <div class="activity-list">
         @foreach($activity as $activityItem)
             <div class="activity-list-item">
-                @include('partials.activity-item', ['activity' => $activityItem])
+                @include('common.activity-item', ['activity' => $activityItem])
             </div>
         @endforeach
     </div>
similarity index 55%
rename from resources/views/partials/custom-head.blade.php
rename to resources/views/common/custom-head.blade.php
index fa5ba0cc456333776de144372098e68fc7f3fe65..6f88bd43f7a9cd77d8d1aec9a85c665fadb27e7c 100644 (file)
@@ -1,5 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
 @if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
 <!-- Custom user content -->
-{!! setting('app-custom-head') !!}
+{!! $headContent->forWeb() !!}
 <!-- End custom user content -->
 @endif
\ No newline at end of file
similarity index 64%
rename from resources/views/pages/detailed-listing.blade.php
rename to resources/views/common/detailed-listing-paginated.blade.php
index c2bbdb53711629d86bc7a3272f8fcab12ff6ba20..d2606d816c956eb4deb666bb970d0c4f0889c980 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small pt-xl">
@@ -6,11 +6,11 @@
             <h1 class="list-heading">{{ $title }}</h1>
 
             <div class="book-contents">
-                @include('partials.entity-list', ['entities' => $pages, 'style' => 'detailed'])
+                @include('entities.list', ['entities' => $entities, 'style' => 'detailed'])
             </div>
 
             <div class="text-center">
-                {!! $pages->links() !!}
+                {!! $entities->links() !!}
             </div>
         </main>
     </div>
diff --git a/resources/views/common/detailed-listing-with-more.blade.php b/resources/views/common/detailed-listing-with-more.blade.php
new file mode 100644 (file)
index 0000000..5413d23
--- /dev/null
@@ -0,0 +1,19 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small pt-xl">
+        <main class="card content-wrap">
+            <h1 class="list-heading">{{ $title }}</h1>
+
+            <div class="book-contents">
+                @include('entities.list', ['entities' => $entities, 'style' => 'detailed'])
+            </div>
+
+            <div class="text-right">
+                @if($hasMoreLink)
+                    <a href="{{ $hasMoreLink }}" class="button outline">{{ trans('common.more') }}</a>
+                @endif
+            </div>
+        </main>
+    </div>
+@stop
\ No newline at end of file
diff --git a/resources/views/common/export-custom-head.blade.php b/resources/views/common/export-custom-head.blade.php
new file mode 100644 (file)
index 0000000..2452d6b
--- /dev/null
@@ -0,0 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
+@if(setting('app-custom-head'))
+<!-- Custom user content -->
+{!! $headContent->forExport() !!}
+<!-- End custom user content -->
+@endif
\ No newline at end of file
similarity index 71%
rename from resources/views/partials/export-styles.blade.php
rename to resources/views/common/export-styles.blade.php
index 967dc19ec709264de9302900d967189358aa39e8..ee10637dddf4e0a0022f2feb4e78bc08d38a165f 100644 (file)
             clear: both;
             display: block;
         }
+
+        @if($engine === \BookStack\Entities\Tools\PdfGenerator::ENGINE_DOMPDF)
+        {{-- Fix for full width linked image sizes on DOMPDF --}}
+        .page-content a > img {
+            max-width: 700px;
+        }
+        {{-- Undoes the above for table images to prevent visually worse scenario, Awaiting next DOMPDF release for patch --}}
+        .page-content td a > img {
+            max-width: 100%;
+        }
+        @endif
     </style>
 @endif
\ No newline at end of file
index 67b52a609e686881e6286c9f7ad028ab6f8b494a..dd488dce541a68a7194d113a73fe9ce1a8ea32cb 100644 (file)
@@ -1,7 +1,7 @@
 @if(count(setting('app-footer-links', [])) > 0)
 <footer>
     @foreach(setting('app-footer-links', []) as $link)
-        <a href="{{ $link['url'] }}" target="_blank">{{ strpos($link['label'], 'trans::') === 0 ? trans(str_replace('trans::', '', $link['label'])) : $link['label'] }}</a>
+        <a href="{{ $link['url'] }}" target="_blank" rel="noopener">{{ strpos($link['label'], 'trans::') === 0 ? trans(str_replace('trans::', '', $link['label'])) : $link['label'] }}</a>
     @endforeach
 </footer>
 @endif
\ No newline at end of file
index 4799aba24507ae8bfc397853e616a6d76b8ea4b4..d55f3ae2dacd179a7749d362035a9ed0f2498922 100644 (file)
@@ -61,6 +61,9 @@
                             <span class="name">{{ $currentUser->getShortName(9) }}</span> @icon('caret-down')
                         </span>
                         <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+                            <li>
+                                <a href="{{ url('/favourites') }}">@icon('star'){{ trans('entities.my_favourites') }}</a>
+                            </li>
                             <li>
                                 <a href="{{ $currentUser->getProfileUrl() }}">@icon('user'){{ trans('common.view_profile') }}</a>
                             </li>
                                 <a href="{{ $currentUser->getEditUrl() }}">@icon('edit'){{ trans('common.edit_profile') }}</a>
                             </li>
                             <li>
-                                @if(config('auth.method') === 'saml2')
-                                    <a href="{{ url('/saml2/logout') }}">@icon('logout'){{ trans('auth.logout') }}</a>
-                                @else
-                                    <a href="{{ url('/logout') }}">@icon('logout'){{ trans('auth.logout') }}</a>
-                                @endif
+                                <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
+                                      method="post">
+                                    {{ csrf_field() }}
+                                    <button class="text-muted icon-list-item text-primary">
+                                        @icon('logout'){{ trans('auth.logout') }}
+                                    </button>
+                                </form>
                             </li>
                             <li><hr></li>
                             <li>
-                                @include('partials.dark-mode-toggle')
+                                @include('common.dark-mode-toggle')
                             </li>
                         </ul>
                     </div>
diff --git a/resources/views/common/home-sidebar.blade.php b/resources/views/common/home-sidebar.blade.php
deleted file mode 100644 (file)
index 4c36ce6..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-@if(count($draftPages) > 0)
-    <div id="recent-drafts" class="mb-xl">
-        <h5>{{ trans('entities.my_recent_drafts') }}</h5>
-        @include('partials.entity-list', ['entities' => $draftPages, 'style' => 'compact'])
-    </div>
-@endif
-
-<div class="mb-xl">
-    <h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
-    @include('partials.entity-list', [
-        'entities' => $recents,
-        'style' => 'compact',
-        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
-        ])
-</div>
-
-<div class="mb-xl">
-    <h5><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h5>
-    <div id="recently-updated-pages">
-        @include('partials.entity-list', [
-        'entities' => $recentlyUpdatedPages,
-        'style' => 'compact',
-        'emptyText' => trans('entities.no_pages_recently_updated')
-        ])
-    </div>
-</div>
-
-<div id="recent-activity" class="mb-xl">
-    <h5>{{ trans('entities.recent_activity') }}</h5>
-    @include('partials.activity-list', ['activity' => $activity])
-</div>
\ No newline at end of file
diff --git a/resources/views/common/skip-to-content.blade.php b/resources/views/common/skip-to-content.blade.php
new file mode 100644 (file)
index 0000000..9b8db11
--- /dev/null
@@ -0,0 +1 @@
+<a class="px-m py-s skip-to-content-link print-hidden" href="#main-content">{{ trans('common.skip_to_main_content') }}</a>
\ No newline at end of file
diff --git a/resources/views/components/tag-list.blade.php b/resources/views/components/tag-list.blade.php
deleted file mode 100644 (file)
index f7a9c6c..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-@foreach($entity->tags as $tag)
-    <div class="tag-item primary-background-light">
-        <div class="tag-name"><a href="{{ url('/search?term=%5B' . urlencode($tag->name) .'%5D') }}">@icon('tag'){{ $tag->name }}</a></div>
-        @if($tag->value) <div class="tag-value"><a href="{{ url('/search?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></div> @endif
-    </div>
-@endforeach
\ No newline at end of file
diff --git a/resources/views/components/tag-manager.blade.php b/resources/views/components/tag-manager.blade.php
deleted file mode 100644 (file)
index 9e24ba3..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<div components="tag-manager add-remove-rows"
-     option:add-remove-rows:row-selector=".card"
-     option:add-remove-rows:remove-selector="button.text-neg"
-     option:tag-manager:row-selector=".card:not(.hidden)"
-     refs="tag-manager@add-remove"
-     class="tags">
-
-        <p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
-
-        <div component="sortable-list"
-             option:sortable-list:handle-selector=".handle">
-            @include('components.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []])
-        </div>
-
-        <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
-</div>
\ No newline at end of file
similarity index 76%
rename from resources/views/partials/book-tree.blade.php
rename to resources/views/entities/book-tree.blade.php
index 15b5832897d01756c7fc59b22a89805bdf7a8bbe..ce016143a30bbc645f2784db5e62b89fcac2f6d0 100644 (file)
@@ -7,19 +7,19 @@
     <ul class="sidebar-page-list mt-xs menu entity-list">
         @if (userCan('view', $book))
             <li class="list-item-book book">
-                @include('partials.entity-list-item-basic', ['entity' => $book, 'classes' => ($current->matches($book)? 'selected' : '')])
+                @include('entities.list-item-basic', ['entity' => $book, 'classes' => ($current->matches($book)? 'selected' : '')])
             </li>
         @endif
 
         @foreach($sidebarTree as $bookChild)
             <li class="list-item-{{ $bookChild->getType() }} {{ $bookChild->getType() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
-                @include('partials.entity-list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
+                @include('entities.list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
 
                 @if($bookChild->isA('chapter') && count($bookChild->visible_pages) > 0)
                     <div class="entity-list-item no-hover">
                         <span role="presentation" class="icon text-chapter"></span>
                         <div class="content">
-                            @include('chapters.child-menu', [
+                            @include('chapters.parts.child-menu', [
                                 'chapter' => $bookChild,
                                 'current' => $current,
                                 'isOpen'  => $bookChild->matchesOrContains($current)
similarity index 95%
rename from resources/views/partials/breadcrumb-listing.blade.php
rename to resources/views/entities/breadcrumb-listing.blade.php
index 2a559aa7d5f7f28b39b253698da83c4ab3664051..929f56ed3c62e53b29e6ffde41d84186be90ac0e 100644 (file)
@@ -16,7 +16,7 @@
                    type="text">
         </div>
         <div refs="dropdown-search@loading">
-            @include('partials.loading-icon')
+            @include('common.loading-icon')
         </div>
         <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m"></div>
     </div>
similarity index 96%
rename from resources/views/partials/breadcrumbs.blade.php
rename to resources/views/entities/breadcrumbs.blade.php
index 065aa842026e91ca481dae18a5a606a5bcfe341d..d078d987322a255726ef3e4052e12bf6a0b32770 100644 (file)
@@ -40,7 +40,7 @@
             </a>
         @elseif($isEntity && userCan('view', $crumb))
             @if($breadcrumbCount > 0)
-                @include('partials.breadcrumb-listing', ['entity' => $crumb])
+                @include('entities.breadcrumb-listing', ['entity' => $crumb])
             @endif
             <a href="{{ $crumb->getUrl() }}" class="text-{{$crumb->getType()}} icon-list-item outline-hover">
                 <span>@icon($crumb->getType())</span>
diff --git a/resources/views/entities/copy-considerations.blade.php b/resources/views/entities/copy-considerations.blade.php
new file mode 100644 (file)
index 0000000..6fe50ef
--- /dev/null
@@ -0,0 +1,15 @@
+<p class="text-warn mb-none mt-l">
+    @icon('warning') <strong>{{ trans('entities.copy_consider') }}</strong>
+</p>
+
+<div class="grid half no-gap no-row-gap text-warn mb-m">
+    <ul class="pr-s mb-none">
+        <li>{{ trans('entities.copy_consider_permissions') }}</li>
+        <li>{{ trans('entities.copy_consider_owner') }}</li>
+        <li>{{ trans('entities.copy_consider_images') }}</li>
+    </ul>
+    <ul class="pr-s mb-none">
+        <li>{{ trans('entities.copy_consider_attachments') }}</li>
+        <li>{{ trans('entities.copy_consider_access') }}</li>
+    </ul>
+</div>
\ No newline at end of file
similarity index 52%
rename from resources/views/partials/entity-export-menu.blade.php
rename to resources/views/entities/export-menu.blade.php
index 4d847bcaef801c4700a74a84de8fc7a025fc8fe6..2b0f5c19dd84b9b88130afe5ee289545eae0100a 100644 (file)
@@ -5,8 +5,9 @@
         <span>{{ trans('entities.export') }}</span>
     </div>
     <ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
-        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" rel="noopener">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" rel="noopener">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" rel="noopener">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" rel="noopener">{{ trans('entities.export_md') }} <span class="text-muted float right">.md</span></a></li>
     </ul>
-</div>
\ No newline at end of file
+</div>
similarity index 58%
rename from resources/views/partials/entity-export-meta.blade.php
rename to resources/views/entities/export-meta.blade.php
index a84d0ae85eb277d6e0c65dc39f61059f37bc5841..02a39e78c9218873251da588345113516ab98b0c 100644 (file)
@@ -4,13 +4,13 @@
     @endif
 
     @icon('star'){!! trans('entities.meta_created' . ($entity->createdBy ? '_name' : ''), [
-        'timeLength' => $entity->created_at->toDayDateTimeString(),
-        'user' => htmlentities($entity->createdBy->name),
+        'timeLength' => $entity->created_at->formatLocalized('%e %B %Y %H:%M:%S'),
+        'user' => e($entity->createdBy->name ?? ''),
         ]) !!}
     <br>
 
     @icon('edit'){!! trans('entities.meta_updated' . ($entity->updatedBy ? '_name' : ''), [
-            'timeLength' => $entity->updated_at->toDayDateTimeString(),
-            'user' => htmlentities($entity->updatedBy->name)
+            'timeLength' => $entity->updated_at->formatLocalized('%e %B %Y %H:%M:%S'),
+            'user' => e($entity->updatedBy->name ?? '')
         ]) !!}
 </div>
\ No newline at end of file
diff --git a/resources/views/entities/favourite-action.blade.php b/resources/views/entities/favourite-action.blade.php
new file mode 100644 (file)
index 0000000..49ba6aa
--- /dev/null
@@ -0,0 +1,12 @@
+@php
+ $isFavourite = $entity->isFavourite();
+@endphp
+<form action="{{ url('/favourites/' . ($isFavourite ? 'remove' : 'add')) }}" method="POST">
+    {{ csrf_field() }}
+    <input type="hidden" name="type" value="{{ get_class($entity) }}">
+    <input type="hidden" name="id" value="{{ $entity->id }}">
+    <button type="submit" class="icon-list-item text-primary">
+        <span>@icon($isFavourite ? 'star' : 'star-outline')</span>
+        <span>{{ $isFavourite ? trans('common.unfavourite') : trans('common.favourite') }}</span>
+    </button>
+</form>
\ No newline at end of file
similarity index 76%
rename from resources/views/partials/entity-list-basic.blade.php
rename to resources/views/entities/list-basic.blade.php
index dc5c3f33385c2e29e873169540ebf87d3f34179c..84b91048418e1b5704ffd9c3c1b8668f0265c20f 100644 (file)
@@ -1,7 +1,7 @@
 <div class="entity-list {{ $style ?? '' }}">
     @if(count($entities) > 0)
         @foreach($entities as $index => $entity)
-            @include('partials.entity-list-item-basic', ['entity' => $entity])
+            @include('entities.list-item-basic', ['entity' => $entity])
         @endforeach
     @else
         <p class="text-muted empty-text">
similarity index 90%
rename from resources/views/partials/entity-list-item-basic.blade.php
rename to resources/views/entities/list-item-basic.blade.php
index 2ec4bee5cc07dbf13db6b0978effd5990551cdc4..398c33b93f6c301fc09e0fff3b8a3a2470cae3fe 100644 (file)
@@ -2,7 +2,7 @@
 <a href="{{ $entity->getUrl() }}" class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item" data-entity-type="{{$type}}" data-entity-id="{{$entity->id}}">
     <span role="presentation" class="icon text-{{$type}}">@icon($type)</span>
     <div class="content">
-            <h4 class="entity-list-item-name break-text">{{ $entity->name }}</h4>
+            <h4 class="entity-list-item-name break-text">{{ $entity->preview_name ?? $entity->name }}</h4>
             {{ $slot ?? '' }}
     </div>
 </a>
\ No newline at end of file
diff --git a/resources/views/entities/list-item.blade.php b/resources/views/entities/list-item.blade.php
new file mode 100644 (file)
index 0000000..44e0675
--- /dev/null
@@ -0,0 +1,32 @@
+@component('entities.list-item-basic', ['entity' => $entity])
+
+<div class="entity-item-snippet">
+
+    @if($showPath ?? false)
+        @if($entity->relationLoaded('book') && $entity->book)
+            <span class="text-book">{{ $entity->book->getShortName(42) }}</span>
+            @if($entity->relationLoaded('chapter') && $entity->chapter)
+                <span class="text-muted entity-list-item-path-sep">@icon('chevron-right')</span> <span class="text-chapter">{{ $entity->chapter->getShortName(42) }}</span>
+            @endif
+        @endif
+    @endif
+
+    <p class="text-muted break-text">{{ $entity->preview_content ?? $entity->getExcerpt() }}</p>
+</div>
+
+@if(($showTags ?? false) && $entity->tags->count() > 0)
+    <div class="entity-item-tags mt-xs">
+        @include('entities.tag-list', ['entity' => $entity, 'linked' => false ])
+    </div>
+@endif
+
+@if(($showUpdatedBy ?? false) && $entity->relationLoaded('updatedBy') && $entity->updatedBy)
+    <small title="{{ $entity->updated_at->toDayDateTimeString() }}">
+        {!! trans('entities.meta_updated_name', [
+            'timeLength' => $entity->updated_at->diffForHumans(),
+            'user' => e($entity->updatedBy->name)
+        ]) !!}
+    </small>
+@endif
+
+@endcomponent
\ No newline at end of file
similarity index 65%
rename from resources/views/partials/entity-list.blade.php
rename to resources/views/entities/list.blade.php
index be826f1ac4f495ba2f61d2eff9cdfd779c44d4fa..25673c583baf4a2cbba4f743552d5373423d76db 100644 (file)
@@ -1,7 +1,7 @@
 @if(count($entities) > 0)
     <div class="entity-list {{ $style ?? '' }}">
         @foreach($entities as $index => $entity)
-            @include('partials.entity-list-item', ['entity' => $entity, 'showPath' => $showPath ?? false])
+            @include('entities.list-item', ['entity' => $entity, 'showPath' => $showPath ?? false, 'showTags' => $showTags ?? false])
         @endforeach
     </div>
 @else
similarity index 91%
rename from resources/views/partials/entity-search-results.blade.php
rename to resources/views/entities/search-results.blade.php
index 74619831af01021ec8b01479e73eddb22a845355..a3c4aa8f777910c5abace6c7e234f05891fff9d0 100644 (file)
@@ -9,7 +9,7 @@
     </div>
 
     <div refs="entity-search@loadingBlock">
-        @include('partials.loading-icon')
+        @include('common.loading-icon')
     </div>
     <div class="book-contents" refs="entity-search@searchResults"></div>
 </div>
\ No newline at end of file
similarity index 88%
rename from resources/views/components/entity-selector-popup.blade.php
rename to resources/views/entities/selector-popup.blade.php
index ec8712b6a5bae9d533009d2f0c6b49b22e2c1a65..ab73a014fa8d2a6c52b68c3c0ec97823414164e0 100644 (file)
@@ -5,7 +5,7 @@
                 <div class="popup-title">{{ trans('entities.entity_select') }}</div>
                 <button refs="popup@hide" type="button" class="popup-header-close">x</button>
             </div>
-            @include('components.entity-selector', ['name' => 'entity-selector'])
+            @include('entities.selector', ['name' => 'entity-selector'])
             <div class="popup-footer">
                 <button refs="entity-selector-popup@select" type="button" disabled="true" class="button corner-button">{{ trans('common.select') }}</button>
             </div>
similarity index 77%
rename from resources/views/components/entity-selector.blade.php
rename to resources/views/entities/selector.blade.php
index c71fdff633ad3e8f7ab1c9d002b29e253b661398..5285b0ec46917e84b0deb43d0cce5bc8ac0473f3 100644 (file)
@@ -4,8 +4,8 @@
          option:entity-selector:entity-types="{{ $entityTypes ?? 'book,chapter,page' }}"
          option:entity-selector:entity-permission="{{ $entityPermission ?? 'view' }}">
         <input refs="entity-selector@input" type="hidden" name="{{$name}}" value="">
-        <input type="text" placeholder="{{ trans('common.search') }}" @if($autofocus ?? false) autofocus @endif refs="entity-selector@search">
-        <div class="text-center loading" refs="entity-selector@loading">@include('partials.loading-icon')</div>
+        <input refs="entity-selector@search entity-selector-popup@searchInput" type="text" placeholder="{{ trans('common.search') }}" @if($autofocus ?? false) autofocus @endif>
+        <div class="text-center loading" refs="entity-selector@loading">@include('common.loading-icon')</div>
         <div refs="entity-selector@results"></div>
         @if($showAdd ?? false)
             <div class="entity-selector-add">
diff --git a/resources/views/entities/sibling-navigation.blade.php b/resources/views/entities/sibling-navigation.blade.php
new file mode 100644 (file)
index 0000000..1f64bac
--- /dev/null
@@ -0,0 +1,28 @@
+<div id="sibling-navigation" class="grid half collapse-xs items-center mb-m px-m no-row-gap fade-in-when-active print-hidden">
+    <div>
+        @if($previous)
+            <a href="{{  $previous->getUrl()  }}" class="outline-hover no-link-style block rounded">
+                <div class="px-m pt-xs text-muted">{{ trans('common.previous') }}</div>
+                <div class="inline-block">
+                    <div class="icon-list-item no-hover">
+                        <span class="text-{{ $previous->getType() }} ">@icon($previous->getType())</span>
+                        <span>{{ $previous->getShortName(48) }}</span>
+                    </div>
+                </div>
+            </a>
+        @endif
+    </div>
+    <div>
+        @if($next)
+            <a href="{{  $next->getUrl()  }}" class="outline-hover no-link-style block rounded text-xs-right">
+                <div class="px-m pt-xs text-muted text-xs-right">{{ trans('common.next') }}</div>
+                <div class="inline block">
+                    <div class="icon-list-item no-hover">
+                        <span class="text-{{ $next->getType() }} ">@icon($next->getType())</span>
+                        <span>{{ $next->getShortName(48) }}</span>
+                    </div>
+                </div>
+            </a>
+        @endif
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/entities/tag-list.blade.php b/resources/views/entities/tag-list.blade.php
new file mode 100644 (file)
index 0000000..a49eef3
--- /dev/null
@@ -0,0 +1,3 @@
+@foreach($entity->tags as $tag)
+    @include('entities.tag', ['tag' => $tag])
+@endforeach
\ No newline at end of file
diff --git a/resources/views/entities/tag-manager.blade.php b/resources/views/entities/tag-manager.blade.php
new file mode 100644 (file)
index 0000000..803def0
--- /dev/null
@@ -0,0 +1,19 @@
+<div components="tag-manager add-remove-rows"
+     option:add-remove-rows:row-selector=".card"
+     option:add-remove-rows:remove-selector="button.text-neg"
+     option:tag-manager:row-selector=".card:not(.hidden)"
+     refs="tag-manager@add-remove"
+     class="tags">
+
+    <p class="text-muted small">
+        {!! nl2br(e(trans('entities.tags_explain'))) !!} <br>
+        <a href="{{ url('/tags') }}" target="_blank">{{ trans('entities.tags_view_existing_tags') }}</a>.
+    </p>
+
+    <div component="sortable-list"
+         option:sortable-list:handle-selector=".handle">
+        @include('entities.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []])
+    </div>
+
+    <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/entities/tag.blade.php b/resources/views/entities/tag.blade.php
new file mode 100644 (file)
index 0000000..de4750c
--- /dev/null
@@ -0,0 +1,9 @@
+<div class="tag-item primary-background-light" data-name="{{ $tag->name }}" data-value="{{ $tag->value }}">
+    @if($linked ?? true)
+        <div class="tag-name {{ $tag->highlight_name ? 'highlight' : '' }}"><a href="{{ $tag->nameUrl() }}">@icon('tag'){{ $tag->name }}</a></div>
+        @if($tag->value) <div class="tag-value {{ $tag->highlight_value ? 'highlight' : '' }}"><a href="{{ $tag->valueUrl() }}">{{$tag->value}}</a></div> @endif
+    @else
+        <div class="tag-name {{ $tag->highlight_name ? 'highlight' : '' }}"><span>@icon('tag'){{ $tag->name }}</span></div>
+        @if($tag->value) <div class="tag-value {{ $tag->highlight_value ? 'highlight' : '' }}"><span>{{$tag->value}}</span></div> @endif
+    @endif
+</div>
\ No newline at end of file
index 02f97fc546fcbc195ec04421731fba44b15cfb1d..a4e5a0dd4c115c30ed1a0df46062109061f9f97d 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 <div class="container mt-l">
@@ -6,9 +6,11 @@
     <div class="card mb-xl px-l pb-l pt-l">
         <div class="grid half v-center">
             <div>
-                <h1 class="list-heading">{{ $message ?? trans('errors.404_page_not_found') }}</h1>
-                <h5>{{ trans('errors.sorry_page_not_found') }}</h5>
-                <p>{{ trans('errors.sorry_page_not_found_permission_warning') }}</p>
+                @include('errors.parts.not-found-text', [
+                    'title' => $message ?? trans('errors.404_page_not_found'),
+                    'subtitle' => $subtitle ?? trans('errors.sorry_page_not_found'),
+                    'details' => $details ?? trans('errors.sorry_page_not_found_permission_warning'),
+                ])
             </div>
             <div class="text-right">
                 @if(!signedInUser())
@@ -26,7 +28,7 @@
                 <div class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact'])
+                        @include('entities.list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['page']), 'style' => 'compact'])
                     </div>
                 </div>
             </div>
@@ -34,7 +36,7 @@
                 <div class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact'])
+                        @include('entities.list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['book']), 'style' => 'compact'])
                     </div>
                 </div>
             </div>
@@ -42,7 +44,7 @@
                 <div class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact'])
+                        @include('entities.list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['chapter']), 'style' => 'compact'])
                     </div>
                 </div>
             </div>
index d06ddbc574a2707542a96d97349f86c473d36fdc..d7d58e4c4a0275feba0ed154d82d611fcf66622a 100644 (file)
@@ -1,11 +1,11 @@
-@extends('base')
+@extends('layouts.base')
 
 @section('content')
 
     <div class="container small py-xl">
 
         <main class="card content-wrap auto-height">
-            <div class="body">
+            <div id="main-content" class="body">
                 <h3>{{ trans('errors.error_occurred') }}</h3>
                 <h5 class="mb-m">{{ $message ?? 'An unknown error occurred' }}</h5>
                 <p><a href="{{ url('/') }}" class="button outline">{{ trans('errors.return_home') }}</a></p>
index 29364606b97d24f58cc977d7adc012fd6bb3a788..acf588f4add9ee09fd068a68e2ad74a5a312efec 100644 (file)
@@ -1,12 +1,29 @@
-@extends('simple-layout')
+<!DOCTYPE html>
+<html lang="{{ config('app.lang') }}"
+      dir="{{ config('app.rtl') ? 'rtl' : 'ltr' }}">
+<head>
+    <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title>
 
-@section('content')
+    <!-- Meta -->
+    <meta name="viewport" content="width=device-width">
+    <meta charset="utf-8">
 
-    <div class="container small mt-xl">
-        <div class="card content-wrap auto-height">
-            <h1 class="list-heading">{{ trans('errors.app_down', ['appName' => setting('app-name')]) }}</h1>
-            <p>{{ trans('errors.back_soon') }}</p>
+    <!-- Styles and Fonts -->
+    <link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
+    <link rel="stylesheet" media="print" href="{{ versioned_asset('dist/print-styles.css') }}">
+
+    <!-- Custom Styles & Head Content -->
+    @include('common.custom-styles')
+    @include('common.custom-head')
+</head>
+<body>
+    <div id="content" class="block">
+        <div class="container small mt-xl">
+            <div class="card content-wrap auto-height">
+                <h1 class="list-heading">{{ trans('errors.app_down', ['appName' => setting('app-name')]) }}</h1>
+                <p>{{ trans('errors.back_soon') }}</p>
+            </div>
         </div>
     </div>
-
-@stop
\ No newline at end of file
+</body>
+</html>
diff --git a/resources/views/errors/debug.blade.php b/resources/views/errors/debug.blade.php
new file mode 100644 (file)
index 0000000..e715543
--- /dev/null
@@ -0,0 +1,146 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport"
+          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+    <title>Error: {{ $error }}</title>
+
+    <style>
+        html, body {
+            background-color: #F2F2F2;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
+        }
+
+        html {
+            padding: 0;
+        }
+
+        body {
+            margin: 0;
+            border-top: 6px solid #206ea7;
+        }
+
+        h1 {
+            margin-top: 0;
+        }
+
+        h2 {
+            color: #666;
+            font-size: 1rem;
+            margin-bottom: 0;
+        }
+
+        .container {
+            max-width: 800px;
+            margin: 1rem auto;
+        }
+
+        .panel {
+            background-color: #FFF;
+            border-radius: 3px;
+            box-shadow: 0 1px 6px -1px rgba(0, 0, 0, 0.1);
+            padding: 1rem 2rem;
+            margin: 2rem 1rem;
+        }
+
+        .panel-title {
+            font-weight: bold;
+            font-size: 1rem;
+            color: #FFF;
+            margin-top: 0;
+            margin-bottom: 0;
+            background-color: #206ea7;
+            padding: 0.25rem .5rem;
+            display: inline-block;
+            border-radius: 3px;
+        }
+
+        pre {
+            overflow-x: scroll;
+            background-color: #EEE;
+            border: 1px solid #DDD;
+            padding: .25rem;
+            border-radius: 3px;
+        }
+
+        a {
+            color: #206ea7;
+            text-decoration: none;
+        }
+
+        a:hover, a:focus {
+            text-decoration: underline;
+            color: #105282;
+        }
+
+        ul {
+            margin-left: 0;
+            padding-left: 1rem;
+        }
+
+        li {
+            margin-bottom: .4rem;
+        }
+
+        .notice {
+            margin-top: 2rem;
+            padding: 0 2rem;
+            font-weight: bold;
+            color: #666;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+
+        <p class="notice">
+            WARNING: Application is in debug mode. This mode has the potential to leak confidential
+            information and therefore should not be used in production or publicly
+            accessible environments.
+        </p>
+
+        <div class="panel">
+            <h4 class="panel-title">Error</h4>
+            <h2>{{ $errorClass }}</h2>
+            <h1>{{ $error }}</h1>
+        </div>
+
+        <div class="panel">
+            <h4 class="panel-title">Help Resources</h4>
+            <ul>
+                <li>
+                    <a href="https://www.bookstackapp.com/docs/admin/debugging/" target="_blank">Review BookStack debugging documentation &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://github.com/BookStackApp/BookStack/releases" target="_blank">Ensure your instance is up-to-date &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue+{{ urlencode($error) }}" target="_blank">Search for the issue on GitHub &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://discord.gg/ztkBqR2" target="_blank">Ask for help via Discord &raquo;</a>
+                </li>
+                <li>
+                    <a href="https://duckduckgo.com/?q={{urlencode("BookStack {$error}")}}" target="_blank">Search the error message &raquo;</a>
+                </li>
+            </ul>
+        </div>
+
+        <div class="panel">
+            <h4 class="panel-title">Environment</h4>
+            <ul>
+                @foreach($environment as $label => $text)
+                <li><strong>{{ $label }}:</strong> {{ $text }}</li>
+                @endforeach
+            </ul>
+        </div>
+
+        <div class="panel">
+            <h4 class="panel-title">Stack Trace</h4>
+            <pre>{{ $trace }}</pre>
+        </div>
+
+    </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/resources/views/errors/parts/not-found-text.blade.php b/resources/views/errors/parts/not-found-text.blade.php
new file mode 100644 (file)
index 0000000..5b107b2
--- /dev/null
@@ -0,0 +1,5 @@
+{{--The below text may be dynamic based upon language and scenario.--}}
+{{--It's safer to add new text sections here rather than altering existing ones.--}}
+<h1 class="list-heading">{{ $title }}</h1>
+<h5>{{ $subtitle }}</h5>
+<p>{{ $details }}</p>
\ No newline at end of file
index 255b591aa9ff790ffef3158dedbd477c48101716..fb2d4e259bb691f77909d77a1e93571b63ed33d2 100644 (file)
@@ -4,7 +4,7 @@ $label
 $errors?
 $model?
 --}}
-@include('components.custom-checkbox', [
+@include('form.custom-checkbox', [
     'name' => $name,
     'label' => $label,
     'value' => 'true',
similarity index 88%
rename from resources/views/components/dropzone.blade.php
rename to resources/views/form/dropzone.blade.php
index 6c5ac49298fde46c7558a9cb4c413b082334615c..118761d4c1181b4cd38e9eebb89472a76c8344b8 100644 (file)
@@ -7,6 +7,7 @@
      option:dropzone:url="{{ $url }}"
      option:dropzone:success-message="{{ $successMessage ?? '' }}"
      option:dropzone:remove-message="{{ trans('components.image_upload_remove') }}"
+     option:dropzone:upload-limit="{{ config('app.upload_limit') }}"
      option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}"
      option:dropzone:timeout-message="{{ trans('errors.file_upload_timeout') }}"
 
index 6cf5ab8bdcac9db8b02f3debb384005ddaa27c80..ed04bc04124c9c0302568b8b4135390ce1314289 100644 (file)
         <div>
             <div class="form-group">
                 <label for="owner">{{ trans('entities.permissions_owner') }}</label>
-                @include('components.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by', 'compact' => false])
+                @include('form.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by', 'compact' => false])
             </div>
         </div>
     </div>
 
+    @if($model instanceof \BookStack\Entities\Models\Bookshelf)
+        <p class="text-warn">{{ trans('entities.shelves_permissions_cascade_warning') }}</p>
+    @endif
+
     <hr>
 
     <table permissions-table class="table permissions-table toggle-switch-list" style="{{ !$model->restricted ? 'display: none' : '' }}">
         <tr>
             <th>{{ trans('common.role') }}</th>
-            <th @if($model->isA('page')) colspan="3" @else colspan="4" @endif>
+            <th colspan="{{ $model->isA('page') ? '3' : '4'  }}">
                 {{ trans('common.actions') }}
                 <a href="#" permissions-table-toggle-all class="text-small ml-m text-primary">{{ trans('common.toggle_all') }}</a>
             </th>
diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php
new file mode 100644 (file)
index 0000000..03cd4be
--- /dev/null
@@ -0,0 +1,3 @@
+@if($errors->has($name))
+    <div class="text-neg text-small">{{ $errors->first($name) }}</div>
+@endif
\ No newline at end of file
diff --git a/resources/views/form/number.blade.php b/resources/views/form/number.blade.php
new file mode 100644 (file)
index 0000000..a37cd36
--- /dev/null
@@ -0,0 +1,12 @@
+<input type="number" id="{{ $name }}" name="{{ $name }}"
+       @if($errors->has($name)) class="text-neg" @endif
+       @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
+       @if($autofocus ?? false) autofocus @endif
+       @if($disabled ?? false) disabled="disabled" @endif
+       @if($readonly ?? false) readonly="readonly" @endif
+       @if($min ?? false) min="{{ $min }}" @endif
+       @if($max ?? false) max="{{ $max }}" @endif
+       @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif>
+@if($errors->has($name))
+    <div class="text-neg text-small">{{ $errors->first($name) }}</div>
+@endif
diff --git a/resources/views/form/request-query-inputs.blade.php b/resources/views/form/request-query-inputs.blade.php
new file mode 100644 (file)
index 0000000..4f2fa06
--- /dev/null
@@ -0,0 +1,8 @@
+{{--
+$params - The query paramters to convert to inputs.
+--}}
+@foreach(array_intersect_key(request()->query(), array_flip($params)) as $name => $value)
+    @if ($value)
+    <input type="hidden" name="{{ $name }}" value="{{ $value }}">
+    @endif
+@endforeach
\ No newline at end of file
index 65a94239e78d83d5b9faadd8b7eafcaa627aaac3..02c477f4af2b4aa5561fba622eff78993dd7c0a9 100644 (file)
@@ -5,7 +5,7 @@ $role
 $action
 $model?
 --}}
-@include('components.custom-checkbox', [
+@include('form.custom-checkbox', [
     'name' => $name . '[' . $role->id . '][' . $action . ']',
     'label' => $label,
     'value' => 'true',
index fc6ad93a8d13284680fd7fc5b267c2ce6453d89d..7e5ca629a8598a4e9cb5b8603e53d08e0d6c9807 100644 (file)
@@ -2,7 +2,7 @@
 <div class="toggle-switch-list dual-column-content">
     @foreach($roles as $role)
         <div>
-            @include('components.custom-checkbox', [
+            @include('form.custom-checkbox', [
                 'name' => $name . '[' . strval($role->id) . ']',
                 'label' => $role->display_name,
                 'value' => $role->id,
similarity index 96%
rename from resources/views/components/user-select.blade.php
rename to resources/views/form/user-select.blade.php
index 50c731efd6e00352f0c388c112fabc516aadfa53..8823bb0750b5fdb014cae14bf0dcf1ca630ce46f 100644 (file)
@@ -27,7 +27,7 @@
                    type="text">
         </div>
         <div refs="dropdown-search@loading" class="text-center">
-            @include('partials.loading-icon')
+            @include('common.loading-icon')
         </div>
         <div refs="dropdown-search@listContainer" class="dropdown-search-list"></div>
     </div>
similarity index 53%
rename from resources/views/common/home-book.blade.php
rename to resources/views/home/books.blade.php
index 1c18edb246bc1ce93040c7af6325d2a0250647c1..75d4ae14a9daa6b58d67334d73e5327eed11ccbe 100644 (file)
@@ -1,11 +1,11 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('body')
-    @include('books.list', ['books' => $books, 'view' => $view])
+    @include('books.parts.list', ['books' => $books, 'view' => $view])
 @stop
 
 @section('left')
-    @include('common.home-sidebar')
+    @include('home.parts.sidebar')
 @stop
 
 @section('right')
@@ -18,9 +18,9 @@
                     <span>{{ trans('entities.books_create') }}</span>
                 </a>
             @endif
-            @include('partials.view-toggle', ['view' => $view, 'type' => 'books'])
-            @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
-            @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
+            @include('home.parts.expand-toggle', ['classes' => 'text-primary', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
+            @include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-primary'])
         </div>
     </div>
 @stop
similarity index 58%
rename from resources/views/common/home.blade.php
rename to resources/views/home/default.blade.php
index ad503463e46f1db404882fbf58018dfae39b227c..f6a337e5054d4bd7b978fc08c626253270d278ed 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
@@ -6,12 +6,12 @@
         <div class="grid half">
             <div>
                 <div class="icon-list inline block">
-                    @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
+                    @include('home.parts.expand-toggle', ['classes' => 'text-muted text-primary', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
                 </div>
             </div>
             <div class="text-m-right">
                 <div class="icon-list inline block">
-                    @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
+                    @include('common.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
                 </div>
             </div>
         </div>
@@ -24,7 +24,7 @@
                     <div id="recent-drafts" class="card mb-xl">
                         <h3 class="card-title">{{ trans('entities.my_recent_drafts') }}</h3>
                         <div class="px-m">
-                            @include('partials.entity-list', ['entities' => $draftPages, 'style' => 'compact'])
+                            @include('entities.list', ['entities' => $draftPages, 'style' => 'compact'])
                         </div>
                     </div>
                 @endif
@@ -32,7 +32,7 @@
                 <div id="{{ auth()->check() ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
                     <h3 class="card-title">{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h3>
                     <div class="px-m">
-                        @include('partials.entity-list', [
+                        @include('entities.list', [
                         'entities' => $recents,
                         'style' => 'compact',
                         'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
             </div>
 
             <div>
+                @if(count($favourites) > 0)
+                    <div id="top-favourites" class="card mb-xl">
+                        <h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
+                        <div class="px-m">
+                            @include('entities.list', [
+                            'entities' => $favourites,
+                            'style' => 'compact',
+                            ])
+                        </div>
+                        <a href="{{ url('/favourites')  }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
+                    </div>
+                @endif
+
                 <div id="recent-pages" class="card mb-xl">
-                    <h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3>
+                    <h3 class="card-title">{{ trans('entities.recently_updated_pages') }}</h3>
                     <div id="recently-updated-pages" class="px-m">
-                        @include('partials.entity-list', [
+                        @include('entities.list', [
                         'entities' => $recentlyUpdatedPages,
                         'style' => 'compact',
-                        'emptyText' => trans('entities.no_pages_recently_updated')
+                        'emptyText' => trans('entities.no_pages_recently_updated'),
                         ])
                     </div>
+                    <a href="{{ url("/pages/recently-updated") }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
                 </div>
             </div>
 
@@ -58,7 +72,7 @@
                 <div id="recent-activity">
                     <div class="card mb-xl">
                         <h3 class="card-title">{{ trans('entities.recent_activity') }}</h3>
-                        @include('partials.activity-list', ['activity' => $activity])
+                        @include('common.activity-list', ['activity' => $activity])
                     </div>
                 </div>
             </div>
similarity index 91%
rename from resources/views/components/expand-toggle.blade.php
rename to resources/views/home/parts/expand-toggle.blade.php
index 0c14490386b0cfb968c00f7d06a535ab2570a6ea..8ed7ff6e036167cb97a7428a57bb906d2f2e7f1e 100644 (file)
@@ -6,7 +6,7 @@ $key - Unique key for checking existing stored state.
 <button type="button" expand-toggle="{{ $target }}"
    expand-toggle-update-endpoint="{{ url('/settings/users/'. user()->id .'/update-expansion-preference/' . $key) }}"
    expand-toggle-is-open="{{ $isOpen ? 'yes' : 'no' }}"
-   class="text-muted icon-list-item text-primary">
+   class="icon-list-item {{ $classes ?? '' }}">
     <span>@icon('expand-text')</span>
     <span>{{ trans('common.toggle_details') }}</span>
 </button>
diff --git a/resources/views/home/parts/sidebar.blade.php b/resources/views/home/parts/sidebar.blade.php
new file mode 100644 (file)
index 0000000..78f7e7a
--- /dev/null
@@ -0,0 +1,43 @@
+@if(count($draftPages) > 0)
+    <div id="recent-drafts" class="mb-xl">
+        <h5>{{ trans('entities.my_recent_drafts') }}</h5>
+        @include('entities.list', ['entities' => $draftPages, 'style' => 'compact'])
+    </div>
+@endif
+
+@if(count($favourites) > 0)
+    <div id="top-favourites" class="mb-xl">
+        <h5>{{ trans('entities.my_most_viewed_favourites') }}</h5>
+        @include('entities.list', [
+            'entities' => $favourites,
+            'style' => 'compact',
+        ])
+        <a href="{{ url('/favourites')  }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
+    </div>
+@endif
+
+<div class="mb-xl">
+    <h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
+    @include('entities.list', [
+        'entities' => $recents,
+        'style' => 'compact',
+        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
+        ])
+</div>
+
+<div class="mb-xl">
+    <h5>{{ trans('entities.recently_updated_pages') }}</h5>
+    <div id="recently-updated-pages">
+        @include('entities.list', [
+        'entities' => $recentlyUpdatedPages,
+        'style' => 'compact',
+        'emptyText' => trans('entities.no_pages_recently_updated')
+        ])
+    </div>
+    <a href="{{ url('/pages/recently-updated')  }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
+</div>
+
+<div id="recent-activity" class="mb-xl">
+    <h5>{{ trans('entities.recent_activity') }}</h5>
+    @include('common.activity-list', ['activity' => $activity])
+</div>
\ No newline at end of file
similarity index 54%
rename from resources/views/common/home-shelves.blade.php
rename to resources/views/home/shelves.blade.php
index 957fa6578fffd35e349c550fdf521a237fd09440..c525643b9c0fcb568f842067465d8e2a1061c174 100644 (file)
@@ -1,11 +1,11 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('body')
-    @include('shelves.list', ['shelves' => $shelves, 'view' => $view])
+    @include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view])
 @stop
 
 @section('left')
-    @include('common.home-sidebar')
+    @include('home.parts.sidebar')
 @stop
 
 @section('right')
@@ -18,9 +18,9 @@
                     <span>{{ trans('entities.shelves_new_action') }}</span>
                 </a>
             @endif
-            @include('partials.view-toggle', ['view' => $view, 'type' => 'shelves'])
-            @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
-            @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
+            @include('home.parts.expand-toggle', ['classes' => 'text-primary', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
+            @include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-primary'])
         </div>
     </div>
 @stop
similarity index 50%
rename from resources/views/common/home-custom.blade.php
rename to resources/views/home/specific-page.blade.php
index e0820305746c799d5d2deb890849397b98f1f70d..936433b276adb445df7a3713396b645d7728d04f 100644 (file)
@@ -1,25 +1,25 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('body')
     <div class="mt-m">
         <main class="content-wrap card">
             <div class="page-content" page-display="{{ $customHomepage->id }}">
-                @include('pages.page-display', ['page' => $customHomepage])
+                @include('pages.parts.page-display', ['page' => $customHomepage])
             </div>
         </main>
     </div>
 @stop
 
 @section('left')
-    @include('common.home-sidebar')
+    @include('home.parts.sidebar')
 @stop
 
 @section('right')
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
-            @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
+            @include('home.parts.expand-toggle', ['classes' => 'text-primary', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
+            @include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-primary'])
         </div>
     </div>
 @stop
\ No newline at end of file
similarity index 74%
rename from resources/views/base.blade.php
rename to resources/views/layouts/base.blade.php
index 66604345f3bfb0c4136138eb6269b116dc1ea7da..1f28e354ce8e119f03a0281fde89563eaee371d5 100644 (file)
     <meta name="base-url" content="{{ url('/') }}">
     <meta charset="utf-8">
 
+    <!-- Social Cards Meta -->
+    <meta property="og:title" content="{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}">
+    <meta property="og:url" content="{{ url()->current() }}">
+    @stack('social-meta')
+
     <!-- Styles and Fonts -->
     <link rel="stylesheet" href="{{ versioned_asset('dist/styles.css') }}">
     <link rel="stylesheet" media="print" href="{{ versioned_asset('dist/print-styles.css') }}">
@@ -18,8 +23,8 @@
     @yield('head')
 
     <!-- Custom Styles & Head Content -->
-    @include('partials.custom-styles')
-    @include('partials.custom-head')
+    @include('common.custom-styles')
+    @include('common.custom-head')
 
     @stack('head')
 
@@ -28,7 +33,8 @@
 </head>
 <body class="@yield('body-class')">
 
-    @include('partials.notifications')
+    @include('common.skip-to-content')
+    @include('common.notifications')
     @include('common.header')
 
     <div id="content" components="@yield('content-components')" class="block">
@@ -44,7 +50,7 @@
     </div>
 
     @yield('bottom')
-    <script src="{{ versioned_asset('dist/app.js') }}"></script>
+    <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
     @yield('scripts')
 
 </body>
similarity index 64%
rename from resources/views/export-layout.blade.php
rename to resources/views/layouts/export.blade.php
index f23b3cca517ec26a2714a46df8fa2e6e3635a0a2..a951e262de7b368c821f3b3b5c3b4bd0fca095ed 100644 (file)
@@ -4,8 +4,8 @@
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <title>@yield('title')</title>
 
-    @include('partials.export-styles', ['format' => $format])
-    @include('partials.export-custom-head')
+    @include('common.export-styles', ['format' => $format, 'engine' => $engine ?? ''])
+    @include('common.export-custom-head')
 </head>
 <body>
 <div class="page-content">
similarity index 68%
rename from resources/views/simple-layout.blade.php
rename to resources/views/layouts/simple.blade.php
index a57aaebef64f39f45460c27dfc6d955ff2ce74d4..5fb231bdb00f0201d09f9c8d75d50ee40cb03d7b 100644 (file)
@@ -1,10 +1,10 @@
-@extends('base')
+@extends('layouts.base')
 
 @section('content')
 
     <div class="flex-fill flex">
         <div class="content flex">
-            <div class="scroll-body">
+            <div id="main-content" class="scroll-body">
                 @yield('body')
             </div>
         </div>
similarity index 94%
rename from resources/views/tri-layout.blade.php
rename to resources/views/layouts/tri.blade.php
index dc4115f291f1a874aa794cb7525a89a6db6fd39d..e95b21445295e851368ead241ef92d08353a6cdc 100644 (file)
@@ -1,4 +1,4 @@
-@extends('base')
+@extends('layouts.base')
 
 @section('body-class', 'tri-layout')
 @section('content-components', 'tri-layout')
@@ -34,7 +34,7 @@
         </div>
 
         <div class="@yield('body-wrap-classes') tri-layout-middle">
-            <div class="tri-layout-middle-contents">
+            <div id="main-content" class="tri-layout-middle-contents">
                 @yield('body')
             </div>
         </div>
diff --git a/resources/views/mfa/backup-codes-generate.blade.php b/resources/views/mfa/backup-codes-generate.blade.php
new file mode 100644 (file)
index 0000000..27ab031
--- /dev/null
@@ -0,0 +1,36 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container very-small py-xl">
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('auth.mfa_gen_backup_codes_title') }}</h1>
+            <p>{{ trans('auth.mfa_gen_backup_codes_desc') }}</p>
+
+            <div class="text-center mb-xs">
+                <div class="text-bigger code-base p-m" style="column-count: 2">
+                    @foreach($codes as $code)
+                        {{ $code }} <br>
+                    @endforeach
+                </div>
+            </div>
+
+            <p class="text-right">
+                <a href="{{ $downloadUrl }}" download="backup-codes.txt" class="button outline small">{{ trans('auth.mfa_gen_backup_codes_download') }}</a>
+            </p>
+
+            <p class="callout warning">
+                {{ trans('auth.mfa_gen_backup_codes_usage_warning') }}
+            </p>
+
+            <form action="{{ url('/mfa/backup_codes/confirm') }}" method="POST">
+                {{ csrf_field() }}
+                <div class="mt-s text-right">
+                    <a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button class="button">{{ trans('auth.mfa_gen_confirm_and_enable') }}</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/mfa/parts/setup-method-row.blade.php b/resources/views/mfa/parts/setup-method-row.blade.php
new file mode 100644 (file)
index 0000000..e195174
--- /dev/null
@@ -0,0 +1,30 @@
+<div class="grid half gap-xl">
+    <div>
+        <div class="setting-list-label">{{ trans('auth.mfa_option_' . $method . '_title') }}</div>
+        <p class="small">
+            {{ trans('auth.mfa_option_' . $method . '_desc') }}
+        </p>
+    </div>
+    <div class="pt-m">
+        @if($userMethods->has($method))
+            <div class="text-pos">
+                @icon('check-circle')
+                {{ trans('auth.mfa_setup_configured') }}
+            </div>
+            <a href="{{ url('/mfa/' . $method . '/generate') }}" class="button outline small">{{ trans('auth.mfa_setup_reconfigure') }}</a>
+            <div component="dropdown" class="inline relative">
+                <button type="button" refs="dropdown@toggle" class="button outline small">{{ trans('common.remove') }}</button>
+                <div refs="dropdown@menu" class="dropdown-menu">
+                    <p class="text-neg small px-m mb-xs">{{ trans('auth.mfa_setup_remove_confirmation') }}</p>
+                    <form action="{{ url('/mfa/' . $method . '/remove') }}" method="post">
+                        {{ csrf_field() }}
+                        {{ method_field('delete') }}
+                        <button class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                    </form>
+                </div>
+            </div>
+        @else
+            <a href="{{ url('/mfa/' . $method . '/generate') }}" class="button outline">{{ trans('auth.mfa_setup_action') }}</a>
+        @endif
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/mfa/parts/verify-backup_codes.blade.php b/resources/views/mfa/parts/verify-backup_codes.blade.php
new file mode 100644 (file)
index 0000000..0e5b820
--- /dev/null
@@ -0,0 +1,17 @@
+<div class="setting-list-label">{{ trans('auth.mfa_verify_backup_code') }}</div>
+
+<p class="small mb-m">{{ trans('auth.mfa_verify_backup_code_desc') }}</p>
+
+<form action="{{ url('/mfa/backup_codes/verify') }}" method="post">
+    {{ csrf_field() }}
+    <input type="text"
+           name="code"
+           placeholder="{{ trans('auth.mfa_verify_backup_code_enter_here') }}"
+           class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}">
+    @if($errors->has('code'))
+        <div class="text-neg text-small px-xs">{{ $errors->first('code') }}</div>
+    @endif
+    <div class="mt-s text-right">
+        <button class="button">{{ trans('common.confirm') }}</button>
+    </div>
+</form>
\ No newline at end of file
diff --git a/resources/views/mfa/parts/verify-totp.blade.php b/resources/views/mfa/parts/verify-totp.blade.php
new file mode 100644 (file)
index 0000000..a52d9b6
--- /dev/null
@@ -0,0 +1,18 @@
+<div class="setting-list-label">{{ trans('auth.mfa_option_totp_title') }}</div>
+
+<p class="small mb-m">{{ trans('auth.mfa_verify_totp_desc') }}</p>
+
+<form action="{{ url('/mfa/totp/verify') }}" method="post">
+    {{ csrf_field() }}
+    <input type="text"
+           name="code"
+           autofocus
+           placeholder="{{ trans('auth.mfa_gen_totp_provide_code_here') }}"
+           class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}">
+    @if($errors->has('code'))
+        <div class="text-neg text-small px-xs">{{ $errors->first('code') }}</div>
+    @endif
+    <div class="mt-s text-right">
+        <button class="button">{{ trans('common.confirm') }}</button>
+    </div>
+</form>
diff --git a/resources/views/mfa/setup.blade.php b/resources/views/mfa/setup.blade.php
new file mode 100644 (file)
index 0000000..702f007
--- /dev/null
@@ -0,0 +1,18 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small py-xl">
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('auth.mfa_setup') }}</h1>
+            <p class="mb-none"> {{ trans('auth.mfa_setup_desc') }}</p>
+
+            <div class="setting-list">
+                @foreach(['totp', 'backup_codes'] as $method)
+                    @include('mfa.parts.setup-method-row', ['method' => $method])
+                @endforeach
+            </div>
+
+        </div>
+    </div>
+@stop
diff --git a/resources/views/mfa/totp-generate.blade.php b/resources/views/mfa/totp-generate.blade.php
new file mode 100644 (file)
index 0000000..e99861a
--- /dev/null
@@ -0,0 +1,40 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container very-small py-xl">
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('auth.mfa_gen_totp_title') }}</h1>
+            <p>{{ trans('auth.mfa_gen_totp_desc') }}</p>
+            <p>{{ trans('auth.mfa_gen_totp_scan') }}</p>
+
+            <div class="text-center">
+                <div class="block inline">
+                    {!! $svg !!}
+                </div>
+                <div class="code-base small text-muted px-s py-xs my-xs" style="overflow-x: scroll; white-space: nowrap;">
+                    {{ $url }}
+                </div>
+            </div>
+
+            <h2 class="list-heading">{{ trans('auth.mfa_gen_totp_verify_setup') }}</h2>
+            <p id="totp-verify-input-details" class="mb-s">{{ trans('auth.mfa_gen_totp_verify_setup_desc') }}</p>
+            <form action="{{ url('/mfa/totp/confirm') }}" method="POST">
+                {{ csrf_field() }}
+                <input type="text"
+                       name="code"
+                       aria-labelledby="totp-verify-input-details"
+                       placeholder="{{ trans('auth.mfa_gen_totp_provide_code_here') }}"
+                       class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}">
+                @if($errors->has('code'))
+                    <div class="text-neg text-small px-xs">{{ $errors->first('code') }}</div>
+                @endif
+                <div class="mt-s text-right">
+                    <a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button class="button">{{ trans('auth.mfa_gen_confirm_and_enable') }}</button>
+                </div>
+            </form>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/mfa/verify.blade.php b/resources/views/mfa/verify.blade.php
new file mode 100644 (file)
index 0000000..3cadeac
--- /dev/null
@@ -0,0 +1,35 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container very-small py-xl">
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('auth.mfa_verify_access') }}</h1>
+            <p class="mb-none">{{ trans('auth.mfa_verify_access_desc') }}</p>
+
+            @if(!$method)
+                <hr class="my-l">
+                <h5>{{ trans('auth.mfa_verify_no_methods') }}</h5>
+                <p class="small">{{ trans('auth.mfa_verify_no_methods_desc') }}</p>
+                <div>
+                    <a href="{{ url('/mfa/setup') }}" class="button outline">{{ trans('common.configure') }}</a>
+                </div>
+            @endif
+
+            @if($method)
+                <hr class="my-l">
+                @include('mfa.parts.verify-' . $method)
+            @endif
+
+            @if(count($otherMethods) > 0)
+                <hr class="my-l">
+                @foreach($otherMethods as $otherMethod)
+                    <div class="text-center">
+                        <a href="{{ url("/mfa/verify?method={$otherMethod}") }}">{{ trans('auth.mfa_verify_use_' . $otherMethod) }}</a>
+                    </div>
+                @endforeach
+            @endif
+
+        </div>
+    </div>
+@stop
index 0f2af0476e17143b5f8a48df42fccd75d0892f2a..9f249863a9f2be0f9e06668e6530e36e11d64eb9 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $page->book,
                 $page->chapter,
                 $page,
                         <label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label>
                     </button>
                     <div class="collapse-content" collapsible-content>
-                        @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create'])
+                        @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create'])
                     </div>
                 </div>
 
+                @include('entities.copy-considerations')
+
                 <div class="form-group text-right">
                     <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
                     <button type="submit" class="button">{{ trans('entities.pages_copy') }}</button>
index 2ec046fa03bf1bf39b4a89ff45bfefa8ebf58be6..39cd07bbb1c227070008cce0f0df5b776b006177 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $page->book,
                 $page->chapter,
                 $page,
index f580b06cf7cd1caeee8a0bb624e5ba20a8411d10..6d2c3d484d43574ede66250a58347cef8c3f2692 100644 (file)
@@ -1,26 +1,26 @@
-@extends('base')
+@extends('layouts.base')
 
 @section('head')
-    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}"></script>
+    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=4.9.4') }}" nonce="{{ $cspNonce }}"></script>
 @stop
 
 @section('body-class', 'flexbox')
 
 @section('content')
 
-    <div class="flex-fill flex fill-height">
+    <div id="main-content" class="flex-fill flex fill-height">
         <form action="{{ $page->getUrl() }}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
             {{ csrf_field() }}
 
             @if(!isset($isDraft))
                 <input type="hidden" name="_method" value="PUT">
             @endif
-            @include('pages.form', ['model' => $page])
-            @include('pages.editor-toolbox')
+            @include('pages.parts.form', ['model' => $page])
+            @include('pages.parts.editor-toolbox')
         </form>
     </div>
     
-    @include('components.image-manager', ['uploaded_to' => $page->id])
-    @include('components.code-editor')
-    @include('components.entity-selector-popup')
+    @include('pages.parts.image-manager', ['uploaded_to' => $page->id])
+    @include('pages.parts.code-editor')
+    @include('entities.selector-popup')
 @stop
\ No newline at end of file
index 74d17c128f794be4c7857c6eb584c07211a3a534..d2f448d6e889bd430f3ce53852530d8411c2945c 100644 (file)
@@ -1,13 +1,13 @@
-@extends('export-layout')
+@extends('layouts.export')
 
 @section('title', $page->name)
 
 @section('content')
-    @include('pages.page-display')
+    @include('pages.parts.page-display')
 
     <hr>
 
     <div class="text-muted text-small">
-        @include('partials.entity-export-meta', ['entity' => $page])
+        @include('entities.export-meta', ['entity' => $page])
     </div>
 @endsection
\ No newline at end of file
index 55db85144ae1f9ba147b4da17268c78cfc2589ec..d6e1cae446aeb729228b537595067eca9c8fcb13 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 ($parent->isA('chapter') ? $parent->book : null),
                 $parent,
                 $parent->getUrl('/create-page') => [
index 26b872cdd769952aa843232daf17d68f32d591b3..6df36496a5d585cba4dc7871a2447e637660ef5f 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $page->book,
                 $page->chapter,
                 $page,
@@ -23,7 +23,7 @@
                 {!! csrf_field() !!}
                 <input type="hidden" name="_method" value="PUT">
 
-                @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create', 'autofocus' => true])
+                @include('entities.selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create', 'autofocus' => true])
 
                 <div class="form-group text-right">
                     <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
similarity index 97%
rename from resources/views/components/code-editor.blade.php
rename to resources/views/pages/parts/code-editor.blade.php
index 011840465a63ff480f048f91b9e9f18bda3baf37..c593d0e2389adde14d7091a3f3d7b73c19a1ae10 100644 (file)
@@ -35,6 +35,7 @@
                             <a refs="code-editor@languageLink" data-lang="shell">Shell/Bash</a>
                             <a refs="code-editor@languageLink" data-lang="SQL">SQL</a>
                             <a refs="code-editor@languageLink" data-lang="VBScript">VBScript</a>
+                            <a refs="code-editor@languageLink" data-lang="VB.NET">VB.NET</a>
                             <a refs="code-editor@languageLink" data-lang="XML">XML</a>
                             <a refs="code-editor@languageLink" data-lang="YAML">YAML</a>
                         </small>
similarity index 86%
rename from resources/views/pages/editor-toolbox.blade.php
rename to resources/views/pages/parts/editor-toolbox.blade.php
index 87a9cc2de8bff90ba2036b0a312399cd41acbbb5..f3b54ddcd6eb0efb0978dea032555a54feeeab1d 100644 (file)
@@ -12,7 +12,7 @@
     <div toolbox-tab-content="tags">
         <h4>{{ trans('entities.page_tags') }}</h4>
         <div class="px-l">
-            @include('components.tag-manager', ['entity' => $page])
+            @include('entities.tag-manager', ['entity' => $page])
         </div>
     </div>
 
@@ -24,7 +24,7 @@
         <h4>{{ trans('entities.templates') }}</h4>
 
         <div class="px-l">
-            @include('pages.template-manager', ['page' => $page, 'templates' => $templates])
+            @include('pages.parts.template-manager', ['page' => $page, 'templates' => $templates])
         </div>
 
     </div>
similarity index 93%
rename from resources/views/pages/form.blade.php
rename to resources/views/pages/parts/form.blade.php
index 7e8b2fdd64409f18a72378c7e620af5f8a14691a..01f68a6c5cf0dfa5ab18e9c6fbbc0c4c5451e3a1 100644 (file)
@@ -20,7 +20,8 @@
         <div class="grid third no-break v-center">
 
             <div class="action-buttons text-left px-m py-xs">
-                <a href="{{ back()->getTargetUrl() }}" class="text-button text-primary">@icon('back')<span class="hide-under-l">{{ trans('common.back') }}</span></a>
+                <a href="{{ $page->draft ? $page->getParent()->getUrl() : $page->getUrl() }}"
+                   class="text-button text-primary">@icon('back')<span class="hide-under-l">{{ trans('common.back') }}</span></a>
             </div>
 
             <div class="text-center px-m py-xs">
 
         {{--WYSIWYG Editor--}}
         @if(setting('app-editor') === 'wysiwyg')
-            @include('pages.wysiwyg-editor', ['model' => $model])
+            @include('pages.parts.wysiwyg-editor', ['model' => $model])
         @endif
 
         {{--Markdown Editor--}}
         @if(setting('app-editor') === 'markdown')
-            @include('pages.markdown-editor', ['model' => $model])
+            @include('pages.parts.markdown-editor', ['model' => $model])
         @endif
 
     </div>
similarity index 92%
rename from resources/views/components/image-manager-form.blade.php
rename to resources/views/pages/parts/image-manager-form.blade.php
index e49a5fca723f8c96fbc1182856b4dae676ac15a8..81041fcac04e4fecd79861301f43ccaca92da2f5 100644 (file)
@@ -7,8 +7,8 @@
           option:ajax-form:url="{{ url('images/' . $image->id) }}">
 
         <div class="image-manager-viewer">
-            <a href="{{ $image->url }}" target="_blank" class="block">
-                <img src="{{ $image->thumbs['display'] }}"
+            <a href="{{ $image->url }}" target="_blank" rel="noopener" class="block">
+                <img src="{{ $image->thumbs['display'] ?? $image->url }}"
                      alt="{{ $image->name }}"
                      class="anim fadeIn"
                      title="{{ $image->name }}">
@@ -40,6 +40,7 @@
                     <li>
                         <a href="{{ $page->url }}"
                            target="_blank"
+                           rel="noopener"
                            class="text-neg">{{ $page->name }}</a>
                     </li>
                 @endforeach
similarity index 98%
rename from resources/views/components/image-manager.blade.php
rename to resources/views/pages/parts/image-manager.blade.php
index 4f03eeaec21c33634992c040bbb56014a6ada5ff..c15c31b86904bc1c7985cb20ac90663c6f053670 100644 (file)
@@ -45,7 +45,7 @@
                 <div class="image-manager-sidebar flex-container-column">
 
                     <div refs="image-manager@dropzoneContainer">
-                        @include('components.dropzone', [
+                        @include('form.dropzone', [
                             'placeholder' => trans('components.image_dropzone'),
                             'successMessage' => trans('components.image_upload_success'),
                             'url' => url('/images/gallery?' . http_build_query(['uploaded_to' => $uploaded_to ?? 0]))
similarity index 60%
rename from resources/views/pages/list-item.blade.php
rename to resources/views/pages/parts/list-item.blade.php
index 1e26cf1d546227566cd2798cd2f305ebbfab8148..5707a9c661933465a2937c2b99d1c433a6d0af66 100644 (file)
@@ -1,4 +1,4 @@
-@component('partials.entity-list-item-basic', ['entity' => $page])
+@component('entities.list-item-basic', ['entity' => $page])
     <div class="entity-item-snippet">
         <p class="text-muted break-text">{{ $page->getExcerpt() }}</p>
     </div>
similarity index 95%
rename from resources/views/pages/markdown-editor.blade.php
rename to resources/views/pages/parts/markdown-editor.blade.php
index 017a971ff1769ab6c57c33e511477f2e3e55d1fc..39d628e17d0ce6ec503d6f8ea76051fa7d1238a2 100644 (file)
@@ -2,6 +2,7 @@
      option:markdown-editor:page-id="{{ $model->id ?? 0 }}"
      option:markdown-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
      option:markdown-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
+     option:markdown-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
      class="flex-fill flex code-fill">
 
     <div class="markdown-editor-wrap active">
similarity index 86%
rename from resources/views/pages/template-manager.blade.php
rename to resources/views/pages/parts/template-manager.blade.php
index fbdb70a1be604ad4fbf0638bf775630f5d65476b..66d53ae7e9fc29d845ef3158fd7933d8a3de0403 100644 (file)
@@ -3,7 +3,7 @@
         <p class="text-muted small mb-none">
             {{ trans('entities.templates_explain_set_as_template') }}
         </p>
-        @include('components.toggle-switch', [
+        @include('form.toggle-switch', [
                'name' => 'template',
                'value' => old('template', $page->template ? 'true' : 'false') === 'true',
                'label' => trans('entities.templates_set_as_template')
@@ -20,6 +20,6 @@
     @endif
 
     <div template-manager-list>
-        @include('pages.template-manager-list', ['templates' => $templates])
+        @include('pages.parts.template-manager-list', ['templates' => $templates])
     </div>
 </div>
\ No newline at end of file
similarity index 85%
rename from resources/views/pages/wysiwyg-editor.blade.php
rename to resources/views/pages/parts/wysiwyg-editor.blade.php
index d8b8b1c353c73f53c10b639635345fe78c86ee14..02948fa2ecd18ed01d2d60754964d7e0207184a3 100644 (file)
@@ -2,6 +2,7 @@
      option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
      option:wysiwyg-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
      option:wysiwyg-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
+     option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
      class="flex-fill flex">
 
     <textarea id="html-editor"  name="html" rows="5"
index de28137dbe02952d52f5cb563d52d95e992074f2..792015e28bb3a405975e3f12b1888ba662c9eb4a 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $page->book,
                 $page->chapter,
                 $page,
index 0557b6b1cd79be45f57d51074bcbf32ab9753b86..b3208c21131ef67890167b8fd8ec724e5191b45e 100644 (file)
@@ -1,10 +1,10 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('left')
     <div id="revision-details" class="entity-details mb-xl">
         <h5>{{ trans('common.details') }}</h5>
         <div class="body text-small text-muted">
-            @include('partials.entity-meta', ['entity' => $revision])
+            @include('entities.meta', ['entity' => $revision])
         </div>
     </div>
 @stop
@@ -12,7 +12,7 @@
 @section('body')
 
     <div class="mb-m print-hidden">
-        @include('partials.breadcrumbs', ['crumbs' => [
+        @include('entities.breadcrumbs', ['crumbs' => [
             $page->$book,
             $page->chapter,
             $page,
@@ -27,7 +27,7 @@
 
     <main class="card content-wrap">
         <div class="page-content page-revision">
-            @include('pages.page-display')
+            @include('pages.parts.page-display')
         </div>
     </main>
 
index 6ff33c68de4b7e2baddc4db1301f111a0161c865..5508f362d3fab131afd4b70e4370394c974361ec 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $page->book,
                 $page->chapter,
                 $page,
                             <td><small>{{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
                             <td>{{ $revision->summary }}</td>
                             <td class="actions">
-                                <a href="{{ $revision->getUrl('changes') }}" target="_blank">{{ trans('entities.pages_revisions_changes') }}</a>
+                                <a href="{{ $revision->getUrl('changes') }}" target="_blank" rel="noopener">{{ trans('entities.pages_revisions_changes') }}</a>
                                 <span class="text-muted">&nbsp;|&nbsp;</span>
 
 
                                 @if ($index === 0)
-                                    <a target="_blank" href="{{ $page->getUrl() }}"><i>{{ trans('entities.pages_revisions_current') }}</i></a>
+                                    <a target="_blank" rel="noopener" href="{{ $page->getUrl() }}"><i>{{ trans('entities.pages_revisions_current') }}</i></a>
                                 @else
-                                    <a href="{{ $revision->getUrl() }}" target="_blank">{{ trans('entities.pages_revisions_preview') }}</a>
+                                    <a href="{{ $revision->getUrl() }}" target="_blank" rel="noopener">{{ trans('entities.pages_revisions_preview') }}</a>
                                     <span class="text-muted">&nbsp;|&nbsp;</span>
                                     <div component="dropdown" class="dropdown-container">
                                         <a refs="dropdown@toggle" href="#" aria-haspopup="true" aria-expanded="false">{{ trans('entities.pages_revisions_restore') }}</a>
index 13125464a7114396012f3685fa56e38ae23b81b9..0111047c6cfec38fe0a2fea304226c7de3a6f114 100644 (file)
@@ -1,9 +1,13 @@
-@extends('tri-layout')
+@extends('layouts.tri')
+
+@push('social-meta')
+    <meta property="og:description" content="{{ Str::limit($page->text, 100, '...') }}">
+@endpush
 
 @section('body')
 
     <div class="mb-m print-hidden">
-        @include('partials.breadcrumbs', ['crumbs' => [
+        @include('entities.breadcrumbs', ['crumbs' => [
             $page->book,
             $page->hasChapter() ? $page->chapter : null,
             $page,
 
     <main class="content-wrap card">
         <div class="page-content clearfix" page-display="{{ $page->id }}">
-            @include('pages.pointer', ['page' => $page])
-            @include('pages.page-display')
+            @include('pages.parts.pointer', ['page' => $page])
+            @include('pages.parts.page-display')
         </div>
     </main>
 
+    @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
+
     @if ($commentsEnabled)
-        <div class="container small p-none comments-container mb-l print-hidden">
+        @if(($previous || $next))
+            <div class="px-xl">
+                <hr class="darker">
+            </div>
+        @endif
+
+        <div class="px-xl comments-container mb-l print-hidden">
             @include('comments.comments', ['page' => $page])
             <div class="clearfix"></div>
         </div>
@@ -29,7 +41,7 @@
 
     @if($page->tags->count() > 0)
         <section>
-            @include('components.tag-list', ['entity' => $page])
+            @include('entities.tag-list', ['entity' => $page])
         </section>
     @endif
 
         </nav>
     @endif
 
-    @include('partials.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
+    @include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
 @stop
 
 @section('right')
     <div id="page-details" class="entity-details mb-xl">
         <h5>{{ trans('common.details') }}</h5>
         <div class="body text-small blended-links">
-            @include('partials.entity-meta', ['entity' => $page])
+            @include('entities.meta', ['entity' => $page])
 
             @if($book->restricted)
                 <div class="active-restriction">
 
             <hr class="primary-background"/>
 
-            {{--Export--}}
-            @include('partials.entity-export-menu', ['entity' => $page])
+            @if(signedInUser())
+                @include('entities.favourite-action', ['entity' => $page])
+            @endif
+            @if(userCan('content-export'))
+                @include('entities.export-menu', ['entity' => $page])
+            @endif
         </div>
 
     </div>
diff --git a/resources/views/pages/sidebar-tree-list.blade.php b/resources/views/pages/sidebar-tree-list.blade.php
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/resources/views/partials/entity-list-item.blade.php b/resources/views/partials/entity-list-item.blade.php
deleted file mode 100644 (file)
index d42b196..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-@component('partials.entity-list-item-basic', ['entity' => $entity])
-<div class="entity-item-snippet">
-
-    @if($showPath ?? false)
-        @if($entity->book_id)
-            <span class="text-book">{{ $entity->book->getShortName(42) }}</span>
-            @if($entity->chapter_id)
-                <span class="text-muted entity-list-item-path-sep">@icon('chevron-right')</span> <span class="text-chapter">{{ $entity->chapter->getShortName(42) }}</span>
-            @endif
-        @endif
-    @endif
-
-    <p class="text-muted break-text">{{ $entity->getExcerpt() }}</p>
-</div>
-@endcomponent
\ No newline at end of file
diff --git a/resources/views/partials/export-custom-head.blade.php b/resources/views/partials/export-custom-head.blade.php
deleted file mode 100644 (file)
index f428e9f..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-@if(setting('app-custom-head'))
-<!-- Custom user content -->
-{!! \BookStack\Util\HtmlContentFilter::removeScripts(setting('app-custom-head')) !!}
-<!-- End custom user content -->
-@endif
\ No newline at end of file
diff --git a/resources/views/readme.md b/resources/views/readme.md
new file mode 100644 (file)
index 0000000..4646db2
--- /dev/null
@@ -0,0 +1,43 @@
+# BookStack Views
+
+All views within this folder are [Laravel blade](https://laravel.com/docs/6.x/blade) views.
+
+### Overriding
+
+Views can be overridden on a per-file basis via the visual theme system.
+More information on this can be found within the `dev/docs/visual-theme-system.md`
+file within this project.
+
+### Convention
+
+Views are broken down into rough domain areas. These aren't too strict although many of the folders
+here will often match up to a HTTP controller. 
+
+Within each folder views will be structured like so:
+
+```txt
+- folder/
+    - page-a.blade.php
+    - page-b.blade.php
+    - parts/
+        - partial-a.blade.php
+        - partial-b.blade.php
+    - subdomain/
+        - subdomain-page-a.blade.php
+        - subdomain-page-b.blade.php
+        - parts/
+            - subdomain-partial-a.blade.php
+            - subdomain-partial-b.blade.php
+```
+
+If a folder contains no pages at all (For example: `attachments`, `form`) and only partials, then 
+the partials can be within the top-level folder instead of pages to prevent unneeded nesting.
+
+If a partial depends on another partial within the same directory, the naming of the child partials should be an extension of the parent.
+For example:
+
+```txt
+- tag-manager.blade.php
+- tag-manager-list.blade.php
+- tag-manager-input.blade.php
+```
\ No newline at end of file
index acf214433b2f646710c5b0052ae820d43b944254..85e6d1b7b525d6ba715030de7c583679f8ad8ea3 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container mt-xl" id="search-system">
                             $types = explode('|', $options->filters['type'] ?? '');
                             $hasTypes = $types[0] !== '';
                             ?>
-                            @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('page', $types), 'entity' => 'page', 'transKey' => 'page'])
-                            @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('chapter', $types), 'entity' => 'chapter', 'transKey' => 'chapter'])
+                            @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('page', $types), 'entity' => 'page', 'transKey' => 'page'])
+                            @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('chapter', $types), 'entity' => 'chapter', 'transKey' => 'chapter'])
                             <br>
-                                @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book'])
-                                @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf'])
+                                @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book'])
+                                @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf'])
                         </div>
 
                         <h6>{{ trans('entities.search_exact_matches') }}</h6>
-                        @include('search.form.term-list', ['type' => 'exact', 'currentList' => $options->exacts])
+                        @include('search.parts.term-list', ['type' => 'exact', 'currentList' => $options->exacts])
 
                         <h6>{{ trans('entities.search_tags') }}</h6>
-                        @include('search.form.term-list', ['type' => 'tags', 'currentList' => $options->tags])
+                        @include('search.parts.term-list', ['type' => 'tags', 'currentList' => $options->tags])
 
                         @if(signedInUser())
                             <h6>{{ trans('entities.search_options') }}</h6>
 
-                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'viewed_by_me', 'value' => null])
+                            @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'viewed_by_me', 'value' => null])
                                 {{ trans('entities.search_viewed_by_me') }}
                             @endcomponent
-                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'not_viewed_by_me', 'value' => null])
+                            @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'not_viewed_by_me', 'value' => null])
                                 {{ trans('entities.search_not_viewed_by_me') }}
                             @endcomponent
-                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'is_restricted', 'value' => null])
+                            @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'is_restricted', 'value' => null])
                                 {{ trans('entities.search_permissions_set') }}
                             @endcomponent
-                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'created_by', 'value' => 'me'])
+                            @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'created_by', 'value' => 'me'])
                                 {{ trans('entities.search_created_by_me') }}
                             @endcomponent
-                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'updated_by', 'value' => 'me'])
+                            @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'updated_by', 'value' => 'me'])
                                 {{ trans('entities.search_updated_by_me') }}
                             @endcomponent
-                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'owned_by', 'value' => 'me'])
+                            @component('search.parts.boolean-filter', ['filters' => $options->filters, 'name' => 'owned_by', 'value' => 'me'])
                                 {{ trans('entities.search_owned_by_me') }}
                             @endcomponent
                         @endif
 
                         <h6>{{ trans('entities.search_date_options') }}</h6>
-                        @include('search.form.date-filter', ['name' => 'updated_after', 'filters' => $options->filters])
-                        @include('search.form.date-filter', ['name' => 'updated_before', 'filters' => $options->filters])
-                        @include('search.form.date-filter', ['name' => 'created_after', 'filters' => $options->filters])
-                        @include('search.form.date-filter', ['name' => 'created_before', 'filters' => $options->filters])
+                        @include('search.parts.date-filter', ['name' => 'updated_after', 'filters' => $options->filters])
+                        @include('search.parts.date-filter', ['name' => 'updated_before', 'filters' => $options->filters])
+                        @include('search.parts.date-filter', ['name' => 'created_after', 'filters' => $options->filters])
+                        @include('search.parts.date-filter', ['name' => 'created_before', 'filters' => $options->filters])
+
+                        @if(isset($options->filters['created_by']))
+                            <input type="hidden" name="filters[created_by]" value="{{ $options->filters['created_by'] }}">
+                        @endif
+                        @if(isset($options->filters['updated_by']))
+                            <input type="hidden" name="filters[updated_by]" value="{{ $options->filters['updated_by'] }}">
+                        @endif
 
                         <button type="submit" class="button">{{ trans('entities.search_update') }}</button>
                     </form>
@@ -77,7 +84,7 @@
 
                     <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6>
                     <div class="book-contents">
-                        @include('partials.entity-list', ['entities' => $entities, 'showPath' => true])
+                        @include('entities.list', ['entities' => $entities, 'showPath' => true, 'showTags' => true])
                     </div>
 
                     @if($hasNextPage)
similarity index 77%
rename from resources/views/search/entity-ajax-list.blade.php
rename to resources/views/search/parts/entity-ajax-list.blade.php
index 36a28b93eb17fe910da3e5e4bd610ec5428f7984..a4eedf75e8c8e3d4a6804b324d84e6fe85cfda7d 100644 (file)
@@ -2,7 +2,7 @@
     @if(count($entities) > 0)
         @foreach($entities as $index => $entity)
 
-            @include('partials.entity-list-item', ['entity' => $entity, 'showPath' => true])
+            @include('entities.list-item', ['entity' => $entity, 'showPath' => true])
             @if($index !== count($entities) - 1)
                 <hr>
             @endif
index 9fda39a317c96e6536576d30d2d7a209e488ada4..48e46a59de254940a3336023367faf3317c6af14 100644 (file)
@@ -1,16 +1,16 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 <div class="container">
 
     <div class="grid left-focus v-center no-row-gap">
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'audit'])
+            @include('settings.parts.navbar', ['selected' => 'audit'])
         </div>
     </div>
 
     <div class="card content-wrap auto-height">
-        <h2 class="list-heading">{{ trans('settings.audit') }}</h2>
+        <h1 class="list-heading">{{ trans('settings.audit') }}</h1>
         <p class="text-muted">{{ trans('settings.audit_desc') }}</p>
 
         <div class="flex-container-row">
                     </div>
                 @endforeach
 
-                <div class="form-group ml-auto" component="submit-on-change">
+                <div class="form-group ml-auto mr-m"
+                     component="submit-on-change"
+                     option:submit-on-change:filter='[name="user"]'>
                     <label for="owner">{{ trans('settings.audit_table_user') }}</label>
-                    @include('components.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' =>  true])
+                    @include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' =>  true])
+                </div>
+
+
+                <div class="form-group ml-auto">
+                    <label for="ip">{{ trans('settings.audit_table_ip') }}</label>
+                    @include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
+                    <input type="submit" style="display: none">
                 </div>
             </form>
         </div>
                     <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
                 </th>
                 <th>{{ trans('settings.audit_table_related') }}</th>
+                <th>{{ trans('settings.audit_table_ip') }}</th>
                 <th>
                     <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
             </tr>
             @foreach($activities as $activity)
                 <tr>
                     <td>
-                        @include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
+                        @include('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
                     </td>
                     <td>{{ $activity->type }}</td>
                     <td width="40%">
@@ -86,6 +96,7 @@
                             <div class="px-m">{{ $activity->detail }}</div>
                         @endif
                     </td>
+                    <td>{{ $activity->ip }}</td>
                     <td>{{ $activity->created_at }}</td>
                 </tr>
             @endforeach
index ad03b6c917fdfecc670a3950650bfa092f7c263a..8b561565853d18c89204504fd42023d65ea5e5fd 100644 (file)
@@ -1,9 +1,9 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
-        @include('settings.navbar-with-version', ['selected' => 'settings'])
+        @include('settings.parts.navbar-with-version', ['selected' => 'settings'])
 
         <div class="card content-wrap auto-height">
             <h2 id="features" class="list-heading">{{ trans('settings.app_features_security') }}</h2>
@@ -25,7 +25,7 @@
                             @endif
                         </div>
                         <div>
-                            @include('components.toggle-switch', [
+                            @include('form.toggle-switch', [
                                 'name' => 'setting-app-public',
                                 'value' => setting('app-public'),
                                 'label' => trans('settings.app_public_access_toggle'),
@@ -39,7 +39,7 @@
                             <p class="small">{{ trans('settings.app_secure_images_desc') }}</p>
                         </div>
                         <div>
-                            @include('components.toggle-switch', [
+                            @include('form.toggle-switch', [
                                 'name' => 'setting-app-secure-images',
                                 'value' => setting('app-secure-images'),
                                 'label' => trans('settings.app_secure_images_toggle'),
@@ -53,7 +53,7 @@
                             <p class="small">{!! trans('settings.app_disable_comments_desc') !!}</p>
                         </div>
                         <div>
-                            @include('components.toggle-switch', [
+                            @include('form.toggle-switch', [
                                 'name' => 'setting-app-disable-comments',
                                 'value' => setting('app-disable-comments'),
                                 'label' => trans('settings.app_disable_comments_toggle'),
@@ -85,7 +85,7 @@
                         </div>
                         <div class="pt-xs">
                             <input type="text" value="{{ setting('app-name', 'BookStack') }}" name="setting-app-name" id="setting-app-name">
-                            @include('components.toggle-switch', [
+                            @include('form.toggle-switch', [
                                 'name' => 'setting-app-name-header',
                                 'value' => setting('app-name-header'),
                                 'label' => trans('settings.app_name_header'),
                             <p class="small">{!! trans('settings.app_logo_desc') !!}</p>
                         </div>
                         <div class="pt-xs">
-                            @include('components.image-picker', [
+                            @include('form.image-picker', [
                                      'removeName' => 'setting-app-logo',
                                      'removeValue' => 'none',
                                      'defaultImage' => url('/logo.png'),
                         </div>
                         <div class="grid half pt-m">
                             <div>
-                                @include('components.setting-entity-color-picker', ['type' => 'bookshelf'])
-                                @include('components.setting-entity-color-picker', ['type' => 'book'])
-                                @include('components.setting-entity-color-picker', ['type' => 'chapter'])
+                                @include('settings.parts.setting-entity-color-picker', ['type' => 'bookshelf'])
+                                @include('settings.parts.setting-entity-color-picker', ['type' => 'book'])
+                                @include('settings.parts.setting-entity-color-picker', ['type' => 'chapter'])
                             </div>
                             <div>
-                                @include('components.setting-entity-color-picker', ['type' => 'page'])
-                                @include('components.setting-entity-color-picker', ['type' => 'page-draft'])
+                                @include('settings.parts.setting-entity-color-picker', ['type' => 'page'])
+                                @include('settings.parts.setting-entity-color-picker', ['type' => 'page-draft'])
                             </div>
                         </div>
                     </div>
                             </select>
 
                             <div page-picker-container style="display: none;" class="mt-m">
-                                @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
+                                @include('settings.parts.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
                             </div>
                         </div>
                     </div>
                     <div>
                         <label for="setting-app-privacy-link" class="setting-list-label">{{ trans('settings.app_footer_links') }}</label>
                         <p class="small mb-m">{{ trans('settings.app_footer_links_desc') }}</p>
-                        @include('settings.footer-links', ['name' => 'setting-app-footer-links', 'value' => setting('app-footer-links', [])])
+                        @include('settings.parts.footer-links', ['name' => 'setting-app-footer-links', 'value' => setting('app-footer-links', [])])
                     </div>
 
 
                             <p class="small">{!! trans('settings.reg_enable_desc') !!}</p>
                         </div>
                         <div>
-                            @include('components.toggle-switch', [
+                            @include('form.toggle-switch', [
                                 'name' => 'setting-registration-enabled',
                                 'value' => setting('registration-enabled'),
                                 'label' => trans('settings.reg_enable_toggle')
                             ])
 
-                            @if(in_array(config('auth.method'), ['ldap', 'saml2']))
+                            @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
                                 <div class="text-warn text-small mb-l">{{ trans('settings.reg_enable_external_warning') }}</div>
                             @endif
 
                             <label for="setting-registration-role">{{ trans('settings.reg_default_role') }}</label>
                             <select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
+                                <option value="0" @if(intval(setting('registration-role', '0')) === 0) selected @endif>-- {{ trans('common.none') }} --</option>
                                 @foreach(\BookStack\Auth\Role::all() as $role)
                                     <option value="{{$role->id}}"
                                             data-system-role-name="{{ $role->system_name ?? '' }}"
-                                            @if(setting('registration-role', \BookStack\Auth\Role::first()->id) == $role->id) selected @endif
+                                            @if(intval(setting('registration-role', '0')) === $role->id) selected @endif
                                     >
                                         {{ $role->display_name }}
                                     </option>
                             <p class="small">{{ trans('settings.reg_confirm_email_desc') }}</p>
                         </div>
                         <div>
-                            @include('components.toggle-switch', [
+                            @include('form.toggle-switch', [
                                 'name' => 'setting-registration-confirmation',
                                 'value' => setting('registration-confirmation'),
                                 'label' => trans('settings.reg_email_confirmation_toggle')
 
     </div>
 
-    @include('components.entity-selector-popup', ['entityTypes' => 'page'])
+    @include('entities.selector-popup', ['entityTypes' => 'page'])
 @stop
index 941a258d84942e32a1c004e4b0b7013f954984f0..ea94413f2f7028673b9d80177f4f14f20d6b2d3e 100644 (file)
@@ -1,9 +1,9 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 <div class="container small">
 
-    @include('settings.navbar-with-version', ['selected' => 'maintenance'])
+    @include('settings.parts.navbar-with-version', ['selected' => 'maintenance'])
 
     <div class="card content-wrap auto-height pb-xl">
         <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
diff --git a/resources/views/settings/navbar-with-version.blade.php b/resources/views/settings/navbar-with-version.blade.php
deleted file mode 100644 (file)
index c02c370..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-{{--
-$selected - String name of the selected tab
-$version - Version of bookstack to display
---}}
-<div class="flex-container-row v-center wrap">
-    <div class="py-m flex fit-content">
-        @include('settings.navbar', ['selected' => $selected])
-    </div>
-    <div class="flex"></div>
-    <div class="text-right p-m flex fit-content">
-        <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
-            BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
-        </a>
-    </div>
-</div>
\ No newline at end of file
diff --git a/resources/views/settings/parts/navbar-with-version.blade.php b/resources/views/settings/parts/navbar-with-version.blade.php
new file mode 100644 (file)
index 0000000..bec4114
--- /dev/null
@@ -0,0 +1,17 @@
+{{--
+$selected - String name of the selected tab
+$version - Version of bookstack to display
+--}}
+<div class="flex-container-row v-center wrap">
+    <div class="py-m flex fit-content">
+        @include('settings.parts.navbar', ['selected' => $selected])
+    </div>
+</div>
+<div class="px-s">
+    <hr class="darker m-none">
+</div>
+<div class="py-l px-m flex fit-content">
+    <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
+        BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
+    </a>
+</div>
\ No newline at end of file
similarity index 82%
rename from resources/views/settings/navbar.blade.php
rename to resources/views/settings/parts/navbar.blade.php
index a472196c56e7bded70e893953f7383918257dca0..f2fad378c2f476ae2f3c4756088aa361eb8970cc 100644 (file)
@@ -13,4 +13,7 @@
     @if(userCan('user-roles-manage'))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
     @endif
+    @if(userCan('settings-manage'))
+        <a href="{{ url('/settings/webhooks') }}" @if($selected == 'webhooks') class="active" @endif>@icon('webhooks'){{ trans('settings.webhooks') }}</a>
+    @endif
 </nav>
\ No newline at end of file
similarity index 78%
rename from resources/views/components/page-picker.blade.php
rename to resources/views/settings/parts/page-picker.blade.php
index c59615d92a30a38fbb0aa66feba92d5a56c0ad1f..0df42e3cef9993f12c7552881a44a76ebbfbcb03 100644 (file)
@@ -3,7 +3,7 @@
 <div page-picker>
     <div class="input-base">
         <span @if($value) style="display: none" @endif page-picker-default class="text-muted italic">{{ $placeholder }}</span>
-        <a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" class="text-page" page-picker-display>#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::find($value)->name : '' }}</a>
+        <a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" rel="noopener" class="text-page" page-picker-display>#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::find($value)->name : '' }}</a>
     </div>
     <br>
     <input type="hidden" value="{{$value}}" name="{{$name}}" id="{{$name}}">
diff --git a/resources/views/settings/recycle-bin/deletable-entity-list.blade.php b/resources/views/settings/recycle-bin/deletable-entity-list.blade.php
deleted file mode 100644 (file)
index 07ad94f..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-@include('partials.entity-display-item', ['entity' => $entity])
-@if($entity->isA('book'))
-    @foreach($entity->chapters()->withTrashed()->get() as $chapter)
-        @include('partials.entity-display-item', ['entity' => $chapter])
-    @endforeach
-@endif
-@if($entity->isA('book') || $entity->isA('chapter'))
-    @foreach($entity->pages()->withTrashed()->get() as $page)
-        @include('partials.entity-display-item', ['entity' => $page])
-    @endforeach
-@endif
\ No newline at end of file
index bd5ef79f0f601bb655de7e3ec256ec3ef729a942..ab603498444c414213263bea440ea83bdee326cc 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'maintenance'])
+            @include('settings.parts.navbar', ['selected' => 'maintenance'])
         </div>
 
         <div class="card content-wrap auto-height">
@@ -20,7 +20,7 @@
             @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
                 <hr class="mt-m">
                 <h5>{{ trans('settings.recycle_bin_destroy_list') }}</h5>
-                @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+                @include('settings.recycle-bin.parts.deletable-entity-list', ['entity' => $deletion->deletable])
             @endif
 
         </div>
index b5de84efa5e212646df8d7ccff5fc546b25dfc77..b31bf02e545e3ac0b4523772e4868918857dcaab 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'maintenance'])
+            @include('settings.parts.navbar', ['selected' => 'maintenance'])
         </div>
 
         <div class="card content-wrap auto-height">
 
             <table class="table">
                 <tr>
-                    <th width="50%">{{ trans('settings.recycle_bin_deleted_item') }}</th>
+                    <th width="30%">{{ trans('settings.recycle_bin_deleted_item') }}</th>
+                    <th width="20%">{{ trans('settings.recycle_bin_deleted_parent') }}</th>
                     <th width="20%">{{ trans('settings.recycle_bin_deleted_by') }}</th>
                     <th width="15%">{{ trans('settings.recycle_bin_deleted_at') }}</th>
                     <th width="15%"></th>
                 </tr>
                 @if(count($deletions) === 0)
                     <tr>
-                        <td colspan="4">
+                        <td colspan="5">
                             <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
                         </td>
                     </tr>
                         </div>
                         @endif
                     </td>
-                    <td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
+                    <td>
+                        @if($deletion->deletable->getParent())
+                        <div class="table-entity-item">
+                            <span role="presentation" class="icon text-{{$deletion->deletable->getParent()->getType()}}">@icon($deletion->deletable->getParent()->getType())</span>
+                            <div class="text-{{ $deletion->deletable->getParent()->getType() }}">
+                                {{ $deletion->deletable->getParent()->name }}
+                            </div>
+                        </div>
+                        @endif
+                    </td>
+                    <td>@include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
                     <td width="200">{{ $deletion->created_at }}</td>
                     <td width="150" class="text-right">
                         <div component="dropdown" class="dropdown-container">
                             <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
                             <ul refs="dropdown@menu" class="dropdown-menu">
-                                <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
-                                <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+                                <li><a class="block" href="{{ $deletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
+                                <li><a class="block" href="{{ $deletion->getUrl('/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
                             </ul>
                         </div>
                     </td>
diff --git a/resources/views/settings/recycle-bin/parts/deletable-entity-list.blade.php b/resources/views/settings/recycle-bin/parts/deletable-entity-list.blade.php
new file mode 100644 (file)
index 0000000..c2d8a42
--- /dev/null
@@ -0,0 +1,11 @@
+@include('settings.recycle-bin.parts.entity-display-item', ['entity' => $entity])
+@if($entity->isA('book'))
+    @foreach($entity->chapters()->withTrashed()->get() as $chapter)
+        @include('settings.recycle-bin.parts.entity-display-item', ['entity' => $chapter])
+    @endforeach
+@endif
+@if($entity->isA('book') || $entity->isA('chapter'))
+    @foreach($entity->pages()->withTrashed()->get() as $page)
+        @include('settings.recycle-bin.parts.entity-display-item', ['entity' => $page])
+    @endforeach
+@endif
\ No newline at end of file
index c888aa8e54db0e1963d8d31c36555842cb21bd6f..5268bf0671cd3ad36ea492bdef3e3479cb5ac4f4 100644 (file)
@@ -1,16 +1,16 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'maintenance'])
+            @include('settings.parts.navbar', ['selected' => 'maintenance'])
         </div>
 
         <div class="card content-wrap auto-height">
             <h2 class="list-heading">{{ trans('settings.recycle_bin_restore') }}</h2>
             <p class="text-muted">{{ trans('settings.recycle_bin_restore_confirm') }}</p>
-            <form action="{{ url('/settings/recycle-bin/' . $deletion->id . '/restore') }}" method="post">
+            <form action="{{ $deletion->getUrl('/restore') }}" method="post">
                 {!! csrf_field() !!}
                 <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
                 <button type="submit" class="button">{{ trans('settings.recycle_bin_restore') }}</button>
             @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
                 <hr class="mt-m">
                 <h5>{{ trans('settings.recycle_bin_restore_list') }}</h5>
-                @if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed())
-                    <p class="text-neg">{{ trans('settings.recycle_bin_restore_deleted_parent') }}</p>
-                @endif
-                @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+                <div class="flex-container-row mb-s items-center">
+                    @if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed())
+                        <div class="text-neg flex">{{ trans('settings.recycle_bin_restore_deleted_parent') }}</div>
+                    @endif
+                    @if($parentDeletion)
+                        <div class="flex fit-content ml-m">
+                            <a class="button outline" href="{{ $parentDeletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore_parent') }}</a>
+                        </div>
+                    @endif
+                </div>
+
+                @include('settings.recycle-bin.parts.deletable-entity-list', ['entity' => $deletion->deletable])
             @endif
 
         </div>
index df902133f3ee514858ae703df5e52b5ab10f934b..72afc60a84f53368d564e20fc800bbd139130716 100644 (file)
@@ -1,16 +1,28 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'roles'])
+            @include('settings.parts.navbar', ['selected' => 'roles'])
         </div>
 
-        <form action="{{ url("/settings/roles/new") }}" method="POST">
-            @include('settings.roles.form', ['title' => trans('settings.role_create')])
-        </form>
+        <div class="card content-wrap">
+            <h1 class="list-heading">{{ trans('settings.role_create') }}</h1>
+
+            <form action="{{ url("/settings/roles/new") }}" method="POST">
+                {{ csrf_field() }}
+
+                @include('settings.roles.parts.form', ['role' => $role ?? null])
+
+                <div class="form-group text-right">
+                    <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('settings.role_save') }}</button>
+                </div>
+            </form>
+
+        </div>
     </div>
 
 @stop
index fa7c12b0a2aafab0ec401e4a951a24966a89afa6..52362461d0295a91ea66f2135c0ee355b2943115 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'roles'])
+            @include('settings.parts.navbar', ['selected' => 'roles'])
         </div>
 
         <div class="card content-wrap auto-height">
index 0f83bdb0becca1370a8a3085055cf376e2a0b1e3..dda8db39d8a70bc7616fe45ef9859046524b0e47 100644 (file)
@@ -1,16 +1,59 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'roles'])
+            @include('settings.parts.navbar', ['selected' => 'roles'])
         </div>
 
-        <form action="{{ url("/settings/roles/{$role->id}") }}" method="POST">
-            <input type="hidden" name="_method" value="PUT">
-            @include('settings.roles.form', ['model' => $role, 'title' => trans('settings.role_edit'), 'icon' => 'edit'])
-        </form>
+        <div class="card content-wrap">
+            <h1 class="list-heading">{{ trans('settings.role_edit') }}</h1>
+
+            <form action="{{ url("/settings/roles/{$role->id}") }}" method="POST">
+                {{ csrf_field() }}
+                {{ method_field('PUT') }}
+
+                @include('settings.roles.parts.form', ['role' => $role])
+
+                <div class="form-group text-right">
+                    <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{ url("/settings/roles/new?copy_from={$role->id}") }}" class="button outline">{{ trans('common.copy') }}</a>
+                    <a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a>
+                    <button type="submit" class="button">{{ trans('settings.role_save') }}</button>
+                </div>
+            </form>
+
+        </div>
+
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.role_users') }}</h2>
+            @if(count($role->users ?? []) > 0)
+                <div class="grid third">
+                    @foreach($role->users as $user)
+                        <div class="user-list-item">
+                            <div>
+                                <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
+                            </div>
+                            <div>
+                                @if(userCan('users-manage') || user()->id == $user->id)
+                                    <a href="{{ url("/settings/users/{$user->id}") }}">
+                                        @endif
+                                        {{ $user->name }}
+                                        @if(userCan('users-manage') || user()->id == $user->id)
+                                    </a>
+                                @endif
+                            </div>
+                        </div>
+                    @endforeach
+                </div>
+            @else
+                <p class="text-muted">
+                    {{ trans('settings.role_users_none') }}
+                </p>
+            @endif
+        </div>
     </div>
 
 @stop
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
deleted file mode 100644 (file)
index 604acbb..0000000
+++ /dev/null
@@ -1,263 +0,0 @@
-{!! csrf_field() !!}
-
-<div class="card content-wrap">
-    <h1 class="list-heading">{{ $title }}</h1>
-
-    <div class="setting-list">
-
-        <div class="grid half">
-            <div>
-                <label class="setting-list-label">{{ trans('settings.role_details') }}</label>
-            </div>
-            <div>
-                <div class="form-group">
-                    <label for="name">{{ trans('settings.role_name') }}</label>
-                    @include('form.text', ['name' => 'display_name'])
-                </div>
-                <div class="form-group">
-                    <label for="name">{{ trans('settings.role_desc') }}</label>
-                    @include('form.text', ['name' => 'description'])
-                </div>
-
-                @if(config('auth.method') === 'ldap' || config('auth.method') === 'saml2')
-                    <div class="form-group">
-                        <label for="name">{{ trans('settings.role_external_auth_id') }}</label>
-                        @include('form.text', ['name' => 'external_auth_id'])
-                    </div>
-                @endif
-            </div>
-        </div>
-
-        <div permissions-table>
-            <label class="setting-list-label">{{ trans('settings.role_system') }}</label>
-            <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-
-            <div class="toggle-switch-list grid half mt-m">
-                <div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
-                </div>
-                <div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div>
-                    <div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
-                    <p class="text-warn text-small mt-s mb-none">{{ trans('settings.roles_system_warning') }}</p>
-                </div>
-            </div>
-        </div>
-
-        <div>
-            <label class="setting-list-label">{{ trans('settings.role_asset') }}</label>
-            <p>{{ trans('settings.role_asset_desc') }}</p>
-
-            @if (isset($role) && $role->system_name === 'admin')
-                <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p>
-            @endif
-
-            <table permissions-table class="table toggle-switch-list compact permissions-table">
-                <tr>
-                    <th width="20%">
-                        <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </th>
-                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.create') }}</th>
-                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.view') }}</th>
-                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.edit') }}</th>
-                    <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.delete') }}</th>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.shelves_long') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.books') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.chapters') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.pages') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.images') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>@include('settings.roles.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td>
-                    <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.attachments') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>@include('settings.roles.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td>
-                    <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-                <tr>
-                    <td>
-                        <div>{{ trans('entities.comments') }}</div>
-                        <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                    </td>
-                    <td>@include('settings.roles.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td>
-                    <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')])
-                    </td>
-                    <td>
-                        @include('settings.roles.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')])
-                        <br>
-                        @include('settings.roles.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')])
-                    </td>
-                </tr>
-            </table>
-        </div>
-    </div>
-
-    <div class="form-group text-right">
-        <a href="{{ url("/settings/roles") }}" class="button outline">{{ trans('common.cancel') }}</a>
-        @if (isset($role) && $role->id)
-            <a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a>
-        @endif
-        <button type="submit" class="button">{{ trans('settings.role_save') }}</button>
-    </div>
-
-</div>
-
-<div class="card content-wrap auto-height">
-    <h2 class="list-heading">{{ trans('settings.role_users') }}</h2>
-    @if(isset($role) && count($role->users) > 0)
-        <div class="grid third">
-            @foreach($role->users as $user)
-                <div class="user-list-item">
-                    <div>
-                        <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
-                    </div>
-                    <div>
-                        @if(userCan('users-manage') || user()->id == $user->id)
-                            <a href="{{ url("/settings/users/{$user->id}") }}">
-                                @endif
-                                {{ $user->name }}
-                                @if(userCan('users-manage') || user()->id == $user->id)
-                            </a>
-                        @endif
-                    </div>
-                </div>
-            @endforeach
-        </div>
-    @else
-        <p class="text-muted">
-            {{ trans('settings.role_users_none') }}
-        </p>
-    @endif
-</div>
index 47cd8c920fffa07909215e12e8f8d3d5d8dedee9..6c2996787a68636aab38bc9c7fbfe2fbae9dc403 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'roles'])
+            @include('settings.parts.navbar', ['selected' => 'roles'])
         </div>
 
         <div class="card content-wrap auto-height">
                 @foreach($roles as $role)
                     <tr>
                         <td><a href="{{ url("/settings/roles/{$role->id}") }}">{{ $role->display_name }}</a></td>
-                        <td>{{ $role->description }}</td>
+                        <td>
+                            @if($role->mfa_enforced)
+                                <span title="{{ trans('settings.role_mfa_enforced') }}">@icon('lock') </span>
+                            @endif
+                            {{ $role->description }}
+                        </td>
                         <td class="text-center">{{ $role->users->count() }}</td>
                     </tr>
                 @endforeach
similarity index 85%
rename from resources/views/settings/roles/checkbox.blade.php
rename to resources/views/settings/roles/parts/checkbox.blade.php
index 98201da8fc7d82af4ad69c867f159531530c2647..44cdd22bdf6f803089e2063b4fb033b55d61b1a2 100644 (file)
@@ -1,5 +1,5 @@
 
-@include('components.custom-checkbox', [
+@include('form.custom-checkbox', [
        'name' => 'permissions[' . $permission . ']',
        'value' => 'true',
        'checked' => old('permissions'.$permission, false)|| (!old('display_name', false) && (isset($role) && $role->hasPermission($permission))),
diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php
new file mode 100644 (file)
index 0000000..a15117e
--- /dev/null
@@ -0,0 +1,224 @@
+<div class="setting-list">
+
+    <div class="grid half">
+        <div>
+            <label class="setting-list-label">{{ trans('settings.role_details') }}</label>
+        </div>
+        <div>
+            <div class="form-group">
+                <label for="display_name">{{ trans('settings.role_name') }}</label>
+                @include('form.text', ['name' => 'display_name', 'model' => $role])
+            </div>
+            <div class="form-group">
+                <label for="description">{{ trans('settings.role_desc') }}</label>
+                @include('form.text', ['name' => 'description', 'model' => $role])
+            </div>
+            <div class="form-group">
+                @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced'), 'model' => $role ])
+            </div>
+
+            @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc']))
+                <div class="form-group">
+                    <label for="name">{{ trans('settings.role_external_auth_id') }}</label>
+                    @include('form.text', ['name' => 'external_auth_id', 'model' => $role])
+                </div>
+            @endif
+        </div>
+    </div>
+
+    <div permissions-table>
+        <label class="setting-list-label">{{ trans('settings.role_system') }}</label>
+        <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+
+        <div class="toggle-switch-list grid half mt-m">
+            <div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
+            </div>
+            <div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'users-manage', 'label' => trans('settings.role_manage_users')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
+                <p class="text-warn text-small mt-s mb-none">{{ trans('settings.roles_system_warning') }}</p>
+            </div>
+        </div>
+    </div>
+
+    <div>
+        <label class="setting-list-label">{{ trans('settings.role_asset') }}</label>
+        <p>{{ trans('settings.role_asset_desc') }}</p>
+
+        @if (isset($role) && $role->system_name === 'admin')
+            <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p>
+        @endif
+
+        <table permissions-table class="table toggle-switch-list compact permissions-table">
+            <tr>
+                <th width="20%">
+                    <a href="#" permissions-table-toggle-all class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </th>
+                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.create') }}</th>
+                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.view') }}</th>
+                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.edit') }}</th>
+                <th width="20%" permissions-table-toggle-all-in-column>{{ trans('common.delete') }}</th>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.shelves_long') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.books') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.chapters') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.pages') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.images') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td>
+                <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.attachments') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td>
+                <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+            <tr>
+                <td>
+                    <div>{{ trans('entities.comments') }}</div>
+                    <a href="#" permissions-table-toggle-all-in-row class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+                </td>
+                <td>@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td>
+                <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')])
+                </td>
+                <td>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')])
+                    <br>
+                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')])
+                </td>
+            </tr>
+        </table>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/settings/webhooks/create.blade.php b/resources/views/settings/webhooks/create.blade.php
new file mode 100644 (file)
index 0000000..f7a99c7
--- /dev/null
@@ -0,0 +1,28 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.webhooks_create') }}</h1>
+
+            <form action="{{ url("/settings/webhooks/create") }}" method="POST">
+                {!! csrf_field() !!}
+                @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
+
+                <div class="form-group text-right">
+                    <a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button>
+                </div>
+            </form>
+        </div>
+
+        @include('settings.webhooks.parts.format-example')
+    </div>
+
+@stop
diff --git a/resources/views/settings/webhooks/delete.blade.php b/resources/views/settings/webhooks/delete.blade.php
new file mode 100644 (file)
index 0000000..65560f6
--- /dev/null
@@ -0,0 +1,39 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading"> {{ trans('settings.webhooks_delete') }}</h1>
+
+            <p>{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}</p>
+
+
+            <form action="{{ $webhook->getUrl() }}" method="POST">
+                {!! csrf_field() !!}
+                {!! method_field('DELETE') !!}
+
+                <div class="grid half v-center">
+                    <div>
+                        <p class="text-neg">
+                            <strong>{{ trans('settings.webhooks_delete_confirm') }}</strong>
+                        </p>
+                    </div>
+                    <div>
+                        <div class="form-group text-right">
+                            <a href="{{ $webhook->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
+                            <button type="submit" class="button">{{ trans('common.confirm') }}</button>
+                        </div>
+                    </div>
+                </div>
+
+
+            </form>
+        </div>
+
+    </div>
+@stop
diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php
new file mode 100644 (file)
index 0000000..27f3070
--- /dev/null
@@ -0,0 +1,54 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.webhooks_edit') }}</h1>
+
+
+            <div class="setting-list">
+            <div class="grid half">
+                <div>
+                    <label class="setting-list-label">{{ trans('settings.webhooks_status') }}</label>
+                    <p class="mb-none">
+                        {{ trans('settings.webhooks_last_called') }} {{ $webhook->last_called_at ? $webhook->last_called_at->diffForHumans() : trans('common.never') }}
+                        <br>
+                        {{ trans('settings.webhooks_last_errored') }} {{ $webhook->last_errored_at ? $webhook->last_errored_at->diffForHumans() : trans('common.never') }}
+                    </p>
+                </div>
+                <div class="text-muted">
+                    <br>
+                    @if($webhook->last_error)
+                        {{ trans('settings.webhooks_last_error_message') }} <br>
+                        <span class="text-warn text-small">{{ $webhook->last_error }}</span>
+                    @endif
+                </div>
+            </div>
+            </div>
+
+
+            <hr>
+
+            <form action="{{ $webhook->getUrl() }}" method="POST">
+                {!! csrf_field() !!}
+                {!! method_field('PUT') !!}
+                @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')])
+
+                <div class="form-group text-right">
+                    <a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{ $webhook->getUrl('/delete') }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a>
+                    <button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button>
+                </div>
+
+            </form>
+        </div>
+
+        @include('settings.webhooks.parts.format-example')
+    </div>
+
+@stop
diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php
new file mode 100644 (file)
index 0000000..296bbd7
--- /dev/null
@@ -0,0 +1,59 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+
+            <div class="grid half v-center">
+                <h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
+
+                <div class="text-right">
+                    <a href="{{ url("/settings/webhooks/create") }}"
+                       class="button outline">{{ trans('settings.webhooks_create') }}</a>
+                </div>
+            </div>
+
+            @if(count($webhooks) > 0)
+
+                <table class="table">
+                    <tr>
+                        <th>{{ trans('common.name') }}</th>
+                        <th width="100">{{ trans('settings.webhook_events_table_header') }}</th>
+                        <th width="100">{{ trans('common.status') }}</th>
+                    </tr>
+                    @foreach($webhooks as $webhook)
+                        <tr>
+                            <td>
+                                <a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a> <br>
+                                <span class="small text-muted italic">{{ $webhook->endpoint }}</span>
+                            </td>
+                            <td>
+                                @if($webhook->tracksEvent('all'))
+                                    {{ trans('settings.webhooks_events_all') }}
+                                @else
+                                    {{ $webhook->trackedEvents->count() }}
+                                @endif
+                            </td>
+                            <td>
+                                {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }}
+                            </td>
+                        </tr>
+                    @endforeach
+                </table>
+            @else
+                <p class="text-muted empty-text px-none">
+                    {{ trans('settings.webhooks_none_created') }}
+                </p>
+            @endif
+
+
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/settings/webhooks/parts/form.blade.php b/resources/views/settings/webhooks/parts/form.blade.php
new file mode 100644 (file)
index 0000000..c8592e2
--- /dev/null
@@ -0,0 +1,64 @@
+<div class="setting-list">
+
+    <div class="grid half">
+        <div>
+            <label class="setting-list-label">{{ trans('settings.webhooks_details') }}</label>
+            <p class="small">{{ trans('settings.webhooks_details_desc') }}</p>
+            <div>
+                @include('form.toggle-switch', [
+                    'name' => 'active',
+                    'value' => old('active') ?? $model->active ?? true,
+                    'label' => trans('settings.webhooks_active'),
+                ])
+                @include('form.errors', ['name' => 'active'])
+            </div>
+        </div>
+        <div>
+            <div class="form-group">
+                <label for="name">{{ trans('settings.webhooks_name') }}</label>
+                @include('form.text', ['name' => 'name'])
+            </div>
+            <div class="form-group">
+                <label for="endpoint">{{ trans('settings.webhooks_endpoint') }}</label>
+                @include('form.text', ['name' => 'endpoint'])
+            </div>
+            <div class="form-group">
+                <label for="endpoint">{{ trans('settings.webhooks_timeout') }}</label>
+                @include('form.number', ['name' => 'timeout', 'min' => 1, 'max' => 600])
+            </div>
+        </div>
+    </div>
+
+    <div component="webhook-events">
+        <label class="setting-list-label">{{ trans('settings.webhooks_events') }}</label>
+        @include('form.errors', ['name' => 'events'])
+
+        <p class="small">{{ trans('settings.webhooks_events_desc') }}</p>
+        <p class="text-warn small">{{ trans('settings.webhooks_events_warning') }}</p>
+
+        <div class="toggle-switch-list">
+            @include('form.custom-checkbox', [
+                'name' => 'events[]',
+                'value' => 'all',
+                'label' => trans('settings.webhooks_events_all'),
+                'checked' => old('events') ? in_array('all', old('events')) : (isset($webhook) ? $webhook->tracksEvent('all') : false),
+            ])
+        </div>
+
+        <hr class="my-s">
+
+        <div class="dual-column-content toggle-switch-list">
+            @foreach(\BookStack\Actions\ActivityType::all() as $activityType)
+                <div>
+                    @include('form.custom-checkbox', [
+                       'name' => 'events[]',
+                       'value' => $activityType,
+                       'label' => $activityType,
+                       'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false),
+                   ])
+                </div>
+            @endforeach
+        </div>
+    </div>
+
+</div>
\ No newline at end of file
diff --git a/resources/views/settings/webhooks/parts/format-example.blade.php b/resources/views/settings/webhooks/parts/format-example.blade.php
new file mode 100644 (file)
index 0000000..135d319
--- /dev/null
@@ -0,0 +1,34 @@
+<div component="code-highlighter" class="card content-wrap auto-height">
+    <h2 class="list-heading">{{ trans('settings.webhooks_format_example') }}</h2>
+    <p>{{ trans('settings.webhooks_format_example_desc') }}</p>
+    <pre><code class="language-json">{
+    "event": "page_update",
+    "text": "Benny updated page \"My wonderful updated page\"",
+    "triggered_at": "2021-12-11T22:25:10.000000Z",
+    "triggered_by": {
+        "id": 1,
+        "name": "Benny",
+        "slug": "benny"
+    },
+    "triggered_by_profile_url": "https://bookstack.local/user/benny",
+    "webhook_id": 2,
+    "webhook_name": "My page update webhook",
+    "url": "https://bookstack.local/books/my-awesome-book/page/my-wonderful-updated-page",
+    "related_item": {
+        "id": 2432,
+        "book_id": 13,
+        "chapter_id": 554,
+        "name": "My wonderful updated page",
+        "slug": "my-wonderful-updated-page",
+        "priority": 2,
+        "created_at": "2021-12-11T21:53:24.000000Z",
+        "updated_at": "2021-12-11T22:25:10.000000Z",
+        "created_by": 1,
+        "updated_by": 1,
+        "draft": false,
+        "revision_count": 9,
+        "template": false,
+        "owned_by": 1
+    }
+}</code></pre>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/_breadcrumbs.blade.php b/resources/views/shelves/_breadcrumbs.blade.php
deleted file mode 100644 (file)
index 91b4252..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-<div class="breadcrumbs">
-    <a href="{{$shelf->getUrl()}}" class="text-bookshelf text-button">@icon('bookshelf'){{ $shelf->getShortName() }}</a>
-</div>
\ No newline at end of file
index bea20eca93624cf234e89b73099b51fc5574c3c4..95b45906862b60fb5f4390d2ab6035fbac696b3b 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 '/shelves' => [
                     'text' => trans('entities.shelves'),
                     'icon' => 'bookshelf',
@@ -20,7 +20,7 @@
         <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.shelves_create') }}</h1>
             <form action="{{ url("/shelves") }}" method="POST" enctype="multipart/form-data">
-                @include('shelves.form', ['shelf' => null, 'books' => $books])
+                @include('shelves.parts.form', ['shelf' => null, 'books' => $books])
             </form>
         </main>
 
index 2a78227bda656ffd611c21dd5570b96c1e8ea3ac..42d1f5d84aeb74b4cd518d3746ff6d8859546b0c 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $shelf,
                 $shelf->getUrl('/delete') => [
                     'text' => trans('entities.shelves_delete'),
index 5ae3638fee955ec3f30ee3c068f5c45e6f7b7976..0114678eb0b38ff9aa1ef292910058e32138e3b3 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $shelf,
                 $shelf->getUrl('/edit') => [
                     'text' => trans('entities.shelves_edit'),
@@ -18,7 +18,7 @@
             <h1 class="list-heading">{{ trans('entities.shelves_edit') }}</h1>
             <form action="{{ $shelf->getUrl() }}" method="POST" enctype="multipart/form-data">
                 <input type="hidden" name="_method" value="PUT">
-                @include('shelves.form', ['model' => $shelf])
+                @include('shelves.parts.form', ['model' => $shelf])
             </form>
         </main>
     </div>
index 21c33aa9c62d1aba748143b8af0e82ac3d01d351..ee52769aa0dc11ab0b23fc9aefdab43ea8bc665e 100644 (file)
@@ -1,7 +1,7 @@
-@extends('tri-layout')
+@extends('layouts.tri')
 
 @section('body')
-    @include('shelves.list', ['shelves' => $shelves, 'view' => $view])
+    @include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view])
 @stop
 
 @section('right')
                     <span>{{ trans('entities.shelves_new_action') }}</span>
                 </a>
             @endif
-            @include('partials.view-toggle', ['view' => $view, 'type' => 'shelves'])
+
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
+
+            <a href="{{ url('/tags') }}" class="icon-list-item">
+                <span>@icon('tag')</span>
+                <span>{{ trans('entities.tags_view_tags') }}</span>
+            </a>
         </div>
     </div>
 
     @if($recents)
         <div id="recents" class="mb-xl">
             <h5>{{ trans('entities.recently_viewed') }}</h5>
-            @include('partials.entity-list', ['entities' => $recents, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $recents, 'style' => 'compact'])
         </div>
     @endif
 
     <div id="popular" class="mb-xl">
         <h5>{{ trans('entities.shelves_popular') }}</h5>
         @if(count($popular) > 0)
-            @include('partials.entity-list', ['entities' => $popular, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $popular, 'style' => 'compact'])
         @else
             <div class="text-muted">{{ trans('entities.shelves_popular_empty') }}</div>
         @endif
@@ -41,7 +47,7 @@
     <div id="new" class="mb-xl">
         <h5>{{ trans('entities.shelves_new') }}</h5>
         @if(count($new) > 0)
-            @include('partials.entity-list', ['entities' => $new, 'style' => 'compact'])
+            @include('entities.list', ['entities' => $new, 'style' => 'compact'])
         @else
             <div class="text-muted">{{ trans('entities.shelves_new_empty') }}</div>
         @endif
similarity index 93%
rename from resources/views/shelves/form.blade.php
rename to resources/views/shelves/parts/form.blade.php
index e635455bfd93a359ae2d3352b716fd9dc399085d..f29c28c8164f5c9330ea626a709f25fabc3663b5 100644 (file)
@@ -2,7 +2,7 @@
 
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
-    @include('form.text', ['name' => 'name'])
+    @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
@@ -46,7 +46,7 @@
     <div class="collapse-content" collapsible-content>
         <p class="small">{{ trans('common.cover_image_description') }}</p>
 
-        @include('components.image-picker', [
+        @include('form.image-picker', [
             'defaultImage' => url('/book_default_cover.png'),
             'currentImage' => (isset($shelf) && $shelf->cover) ? $shelf->getBookCover() : url('/book_default_cover.png') ,
             'name' => 'image',
@@ -60,7 +60,7 @@
         <label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
     </button>
     <div class="collapse-content" collapsible-content>
-        @include('components.tag-manager', ['entity' => $shelf ?? null])
+        @include('entities.tag-manager', ['entity' => $shelf ?? null])
     </div>
 </div>
 
similarity index 83%
rename from resources/views/shelves/list.blade.php
rename to resources/views/shelves/parts/list.blade.php
index 3600a8c795f70303dc464eb73b6fe503ec1bef90..d78606ac700e081818d7c183e880116692a6b938 100644 (file)
@@ -4,7 +4,7 @@
     <div class="grid half v-center">
         <h1 class="list-heading">{{ trans('entities.shelves') }}</h1>
         <div class="text-right">
-            @include('partials.sort', ['options' => $sortOptions, 'order' => $order, 'sort' => $sort, 'type' => 'bookshelves'])
+            @include('entities.sort', ['options' => $sortOptions, 'order' => $order, 'sort' => $sort, 'type' => 'bookshelves'])
         </div>
     </div>
 
                     @if ($index !== 0)
                         <hr class="my-m">
                     @endif
-                    @include('shelves.list-item', ['shelf' => $shelf])
+                    @include('shelves.parts.list-item', ['shelf' => $shelf])
                 @endforeach
             </div>
         @else
             <div class="grid third">
                 @foreach($shelves as $key => $shelf)
-                    @include('partials.entity-grid-item', ['entity' => $shelf])
+                    @include('entities.grid-item', ['entity' => $shelf])
                 @endforeach
             </div>
         @endif
index df50be8dd1644e6300bf7758b015d1e6c61d36d6..a26325518d6ac5294c38c1a89e5c7a1fdeb117c0 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="my-s">
-            @include('partials.breadcrumbs', ['crumbs' => [
+            @include('entities.breadcrumbs', ['crumbs' => [
                 $shelf,
                 $shelf->getUrl('/permissions') => [
                     'text' => trans('entities.shelves_permissions'),
@@ -14,7 +14,7 @@
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <div class="card content-wrap auto-height">
             <h1 class="list-heading">{{ trans('entities.shelves_permissions') }}</h1>
             @include('form.entity-permissions', ['model' => $shelf])
         </div>
index 7ed36c90685ef1d8a978b7fdf8a4a00d50deec61..0d592468d1b8a1adace0bc1776695a779a4bd13a 100644 (file)
@@ -1,9 +1,16 @@
-@extends('tri-layout')
+@extends('layouts.tri')
+
+@push('social-meta')
+    <meta property="og:description" content="{{ Str::limit($shelf->description, 100, '...') }}">
+    @if($shelf->cover)
+        <meta property="og:image" content="{{ $shelf->getBookCover() }}">
+    @endif
+@endpush
 
 @section('body')
 
     <div class="mb-s">
-        @include('partials.breadcrumbs', ['crumbs' => [
+        @include('entities.breadcrumbs', ['crumbs' => [
             $shelf,
         ]])
     </div>
@@ -14,7 +21,7 @@
             <h1 class="flex fit-content break-text">{{ $shelf->name }}</h1>
             <div class="flex"></div>
             <div class="flex fit-content text-m-right my-m ml-m">
-                @include('partials.sort', ['options' => [
+                @include('entities.sort', ['options' => [
                     'default' => trans('common.sort_default'),
                     'name' => trans('common.sort_name'),
                     'created_at' => trans('common.sort_created_at'),
                 @if($view === 'list')
                     <div class="entity-list">
                         @foreach($sortedVisibleShelfBooks as $book)
-                            @include('books.list-item', ['book' => $book])
+                            @include('books.parts.list-item', ['book' => $book])
                         @endforeach
                     </div>
                 @else
                     <div class="grid third">
                         @foreach($sortedVisibleShelfBooks as $book)
-                            @include('partials.entity-grid-item', ['entity' => $book])
+                            @include('entities.grid-item', ['entity' => $book])
                         @endforeach
                     </div>
                 @endif
 
     @if($shelf->tags->count() > 0)
         <div id="tags" class="mb-xl">
-            @include('components.tag-list', ['entity' => $shelf])
+            @include('entities.tag-list', ['entity' => $shelf])
         </div>
     @endif
 
     <div id="details" class="mb-xl">
         <h5>{{ trans('common.details') }}</h5>
         <div class="text-small text-muted blended-links">
-            @include('partials.entity-meta', ['entity' => $shelf])
+            @include('entities.meta', ['entity' => $shelf])
             @if($shelf->restricted)
                 <div class="active-restriction">
                     @if(userCan('restrictions-manage', $shelf))
@@ -91,7 +98,7 @@
     @if(count($activity) > 0)
         <div class="mb-xl">
             <h5>{{ trans('entities.recent_activity') }}</h5>
-            @include('partials.activity-list', ['activity' => $activity])
+            @include('common.activity-list', ['activity' => $activity])
         </div>
     @endif
 @stop
                 </a>
             @endif
 
-            @include('partials.view-toggle', ['view' => $view, 'type' => 'shelf'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelf'])
 
             <hr class="primary-background">
 
                 </a>
             @endif
 
+            @if(signedInUser())
+                <hr class="primary-background">
+                @include('entities.favourite-action', ['entity' => $shelf])
+            @endif
+
         </div>
     </div>
 @stop
diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php
new file mode 100644 (file)
index 0000000..c88449c
--- /dev/null
@@ -0,0 +1,56 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <main class="card content-wrap mt-xxl">
+
+            <div class="flex-container-row wrap justify-space-between items-center mb-s">
+                <h1 class="list-heading">{{ trans('entities.tags') }}</h1>
+
+                <div>
+                    <div class="block inline mr-xs">
+                        <form method="get" action="{{ url("/tags") }}">
+                            @include('form.request-query-inputs', ['params' => ['name']])
+                            <input type="text"
+                                   name="search"
+                                   placeholder="{{ trans('common.search') }}"
+                                   value="{{ $search }}">
+                        </form>
+                    </div>
+                </div>
+            </div>
+
+            @if($nameFilter)
+                <div class="mb-m">
+                    <span class="mr-xs">{{ trans('common.filter_active') }}</span>
+                    @include('entities.tag', ['tag' => new \BookStack\Actions\Tag(['name' => $nameFilter])])
+                    <form method="get" action="{{ url("/tags") }}" class="inline block">
+                        @include('form.request-query-inputs', ['params' => ['search']])
+                        <button class="text-button text-warn">@icon('close'){{ trans('common.filter_clear') }}</button>
+                    </form>
+                </div>
+            @endif
+
+            @if(count($tags) > 0)
+                <table class="table expand-to-padding mt-m">
+                    @foreach($tags as $tag)
+                        @include('tags.parts.table-row', ['tag' => $tag, 'nameFilter' => $nameFilter])
+                    @endforeach
+                </table>
+
+                <div>
+                    {{ $tags->links() }}
+                </div>
+            @else
+                <p class="text-muted italic my-xl">
+                    {{ trans('common.no_items') }}.
+                    <br>
+                    {{ trans('entities.tags_list_empty_hint') }}
+                </p>
+            @endif
+        </main>
+
+    </div>
+
+@stop
diff --git a/resources/views/tags/parts/table-row.blade.php b/resources/views/tags/parts/table-row.blade.php
new file mode 100644 (file)
index 0000000..aa04959
--- /dev/null
@@ -0,0 +1,37 @@
+<tr>
+    <td>
+        <span class="text-bigger mr-xl">@include('entities.tag', ['tag' => $tag])</span>
+    </td>
+    <td width="70" class="px-xs">
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() }}"
+           title="{{ trans('entities.tags_usages') }}"
+           class="pill text-muted">@icon('leaderboard'){{ $tag->usages }}</a>
+    </td>
+    <td width="70" class="px-xs">
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:page}' }}"
+           title="{{ trans('entities.tags_assigned_pages') }}"
+           class="pill text-page">@icon('page'){{ $tag->page_count }}</a>
+    </td>
+    <td width="70" class="px-xs">
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:chapter}' }}"
+           title="{{ trans('entities.tags_assigned_chapters') }}"
+           class="pill text-chapter">@icon('chapter'){{ $tag->chapter_count }}</a>
+    </td>
+    <td width="70" class="px-xs">
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:book}' }}"
+           title="{{ trans('entities.tags_assigned_books') }}"
+           class="pill text-book">@icon('book'){{ $tag->book_count }}</a>
+    </td>
+    <td width="70" class="px-xs">
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:bookshelf}' }}"
+           title="{{ trans('entities.tags_assigned_shelves') }}"
+           class="pill text-bookshelf">@icon('bookshelf'){{ $tag->shelf_count }}</a>
+    </td>
+    <td class="text-right text-muted">
+        @if($tag->values ?? false)
+            <a href="{{ url('/tags?name=' . urlencode($tag->name)) }}">{{ trans('entities.tags_x_unique_values', ['count' => $tag->values]) }}</a>
+        @elseif(empty($nameFilter))
+            <a href="{{ url('/tags?name=' . urlencode($tag->name)) }}">{{ trans('entities.tags_all_values') }}</a>
+        @endif
+    </td>
+</tr>
\ No newline at end of file
index 46c3e0b8a2316b555da6b86414b9dbde39dfc1ad..9cf772082d2e8a16af03d44166880ad68ff9291f 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
@@ -11,7 +11,7 @@
                 {!! csrf_field() !!}
 
                 <div class="setting-list">
-                    @include('users.api-tokens.form')
+                    @include('users.api-tokens.parts.form')
 
                     <div>
                         <p class="text-warn italic">
index 8fcfcda95d6b59541be715bd08536dd72e8af977..45f0e2fa0eb808f9047b4ff6defbecf2d47d1556 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small pt-xl">
index 821a00d93cfa4590ac32ffad7c312d7b1f5c9112..61c1ac2a63e607099ef70fc12e5ab8680e0fa4cf 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
@@ -36,7 +36,7 @@
                         </div>
                     @endif
 
-                    @include('users.api-tokens.form', ['model' => $token])
+                    @include('users.api-tokens.parts.form', ['model' => $token])
                 </div>
 
                 <div class="grid half gap-xl v-center">
index d953b646afe8c0ba10643863cb61fb384de064b5..7015b162acce75391afac50d6756819463e331dd 100644 (file)
@@ -1,11 +1,11 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'users'])
+            @include('settings.parts.navbar', ['selected' => 'users'])
         </div>
 
         <main class="card content-wrap">
@@ -15,7 +15,8 @@
                 {!! csrf_field() !!}
 
                 <div class="setting-list">
-                    @include('users.form')
+                    @include('users.parts.form')
+                    @include('users.parts.language-option-row', ['value' => old('setting.language') ?? config('app.default_locale')])
                 </div>
 
                 <div class="form-group text-right">
index 7b1d38d340b1eb9cdf789806cf9a0421486be0f3..490e9d6c5aa979cf07f8267f043afddb066a1fb8 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'users'])
+            @include('settings.parts.navbar', ['selected' => 'users'])
         </div>
 
         <div class="card content-wrap auto-height">
 
             <p>{{ trans('settings.users_delete_warning', ['userName' => $user->name]) }}</p>
 
-            <hr class="my-l">
-
-            <div class="grid half gap-xl v-center">
-                <div>
-                    <label class="setting-list-label">{{ trans('settings.users_migrate_ownership') }}</label>
-                    <p class="small">{{ trans('settings.users_migrate_ownership_desc') }}</p>
+            @if(userCan('users-manage'))
+                <hr class="my-l">
+
+                <div class="grid half gap-xl v-center">
+                    <div>
+                        <label class="setting-list-label">{{ trans('settings.users_migrate_ownership') }}</label>
+                        <p class="small">{{ trans('settings.users_migrate_ownership_desc') }}</p>
+                    </div>
+                    <div>
+                        @include('form.user-select', ['name' => 'new_owner_id', 'user' => null, 'compact' => false])
+                    </div>
                 </div>
-                <div>
-                    @include('components.user-select', ['name' => 'new_owner_id', 'user' => null, 'compact' => false])
-                </div>
-            </div>
+            @endif
 
             <hr class="my-l">
 
index 7fb12bd757389c0128a0e4f62f36fb03cb6e5808..41e64dbb9f8c86fa52054b55304db697dbbae1e8 100644 (file)
@@ -1,10 +1,10 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'users'])
+            @include('settings.parts.navbar', ['selected' => 'users'])
         </div>
 
         <section class="card content-wrap">
@@ -14,7 +14,7 @@
                 <input type="hidden" name="_method" value="PUT">
 
                 <div class="setting-list">
-                    @include('users.form', ['model' => $user, 'authMethod' => $authMethod])
+                    @include('users.parts.form', ['model' => $user, 'authMethod' => $authMethod])
 
                     <div class="grid half gap-xl">
                         <div>
@@ -22,7 +22,7 @@
                             <p class="small">{{ trans('settings.users_avatar_desc') }}</p>
                         </div>
                         <div>
-                            @include('components.image-picker', [
+                            @include('form.image-picker', [
                                 'resizeHeight' => '512',
                                 'resizeWidth' => '512',
                                 'showRemove' => false,
                         </div>
                     </div>
 
-                    <div class="grid half gap-xl v-center">
-                        <div>
-                            <label for="user-language" class="setting-list-label">{{ trans('settings.users_preferred_language') }}</label>
-                            <p class="small">
-                                {{ trans('settings.users_preferred_language_desc') }}
-                            </p>
-                        </div>
-                        <div>
-                            <select name="setting[language]" id="user-language">
-                                @foreach(trans('settings.language_select') as $lang => $label)
-                                    <option @if(setting()->getUser($user, 'language', config('app.default_locale')) === $lang) selected @endif value="{{ $lang }}">{{ $label }}</option>
-                                @endforeach
-                            </select>
-                        </div>
-                    </div>
-
+                    @include('users.parts.language-option-row', ['value' => setting()->getUser($user, 'language', config('app.default_locale'))])
                 </div>
 
                 <div class="text-right">
             </form>
         </section>
 
+        <section class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.users_mfa') }}</h2>
+            <p>{{ trans('settings.users_mfa_desc') }}</p>
+            <div class="grid half gap-xl v-center pb-s">
+                <div>
+                    @if ($mfaMethods->count() > 0)
+                        <span class="text-pos">@icon('check-circle')</span>
+                    @else
+                        <span class="text-neg">@icon('cancel')</span>
+                    @endif
+                    {{ trans_choice('settings.users_mfa_x_methods', $mfaMethods->count()) }}
+                </div>
+                <div class="text-m-right">
+                    @if($user->id === user()->id)
+                        <a href="{{ url('/mfa/setup')  }}" class="button outline">{{ trans('settings.users_mfa_configure') }}</a>
+                    @endif
+                </div>
+            </div>
+
+        </section>
+
         @if(user()->id === $user->id && count($activeSocialDrivers) > 0)
             <section class="card content-wrap auto-height">
                 <h2 class="list-heading">{{ trans('settings.users_social_accounts') }}</h2>
                                 <div role="presentation">@icon('auth/'. $driver, ['style' => 'width: 56px;height: 56px;'])</div>
                                 <div>
                                     @if($user->hasSocialAccount($driver))
-                                        <a href="{{ url("/login/service/{$driver}/detach") }}" aria-label="{{ trans('settings.users_social_disconnect') }} - {{ $driver }}"
-                                           class="button small outline">{{ trans('settings.users_social_disconnect') }}</a>
+                                        <form action="{{ url("/login/service/{$driver}/detach") }}" method="POST">
+                                            {{ csrf_field() }}
+                                            <button aria-label="{{ trans('settings.users_social_disconnect') }} - {{ $driver }}"
+                                                    class="button small outline">{{ trans('settings.users_social_disconnect') }}</button>
+                                        </form>
                                     @else
                                         <a href="{{ url("/login/service/{$driver}") }}" aria-label="{{ trans('settings.users_social_connect') }} - {{ $driver }}"
                                            class="button small outline">{{ trans('settings.users_social_connect') }}</a>
@@ -89,7 +98,7 @@
         @endif
 
         @if((user()->id === $user->id && userCan('access-api')) || userCan('users-manage'))
-            @include('users.api-tokens.list', ['user' => $user])
+            @include('users.api-tokens.parts.list', ['user' => $user])
         @endif
     </div>
 
index 6bc229ec682a1fac2ae1599b4e1bfe0717c406a2..6c79169ca2dbc296857a487c4e376a66387080a1 100644 (file)
@@ -1,19 +1,19 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
         <div class="py-m">
-            @include('settings.navbar', ['selected' => 'users'])
+            @include('settings.parts.navbar', ['selected' => 'users'])
         </div>
 
         <main class="card content-wrap">
 
-            <div class="grid right-focus v-center">
+            <div class="flex-container-row wrap justify-space-between items-center">
                 <h1 class="list-heading">{{ trans('settings.users') }}</h1>
 
-                <div class="text-right">
-                    <div class="block inline mr-s">
+                <div>
+                    <div class="block inline mr-xs">
                         <form method="get" action="{{ url("/settings/users") }}">
                             @foreach(collect($listDetails)->except('search') as $name => $val)
                                 <input type="hidden" name="{{ $name }}" value="{{ $val }}">
                         <td class="text-center" style="line-height: 0;"><img class="avatar med" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></td>
                         <td>
                             <a href="{{ url("/settings/users/{$user->id}") }}">
-                                {{ $user->name }} <br> <span class="text-muted">{{ $user->email }}</span>
+                                {{ $user->name }}
+                                <br>
+                                <span class="text-muted">{{ $user->email }}</span>
+                                @if($user->mfa_values_count > 0)
+                                    <span title="MFA Configured" class="text-pos">@icon('lock')</span>
+                                @endif
                             </a>
                         </td>
                         <td>
similarity index 95%
rename from resources/views/users/form.blade.php
rename to resources/views/users/parts/form.blade.php
index 763c387d4601bca12bba95b71dd9ba2cfecb200f..2a5002c3b766c34cc97494336130268c448b499d 100644 (file)
@@ -25,7 +25,7 @@
     </div>
 </div>
 
-@if(($authMethod === 'ldap' || $authMethod === 'saml2') && userCan('users-manage'))
+@if(in_array($authMethod, ['ldap', 'saml2', 'oidc']) && userCan('users-manage'))
     <div class="grid half gap-xl v-center">
         <div>
             <label class="setting-list-label">{{ trans('settings.users_external_auth_id') }}</label>
@@ -56,7 +56,7 @@
                 {{ trans('settings.users_send_invite_text') }}
             </p>
 
-            @include('components.toggle-switch', [
+            @include('form.toggle-switch', [
                 'name' => 'send_invite',
                 'value' => old('send_invite', 'true') === 'true',
                 'label' => trans('settings.users_send_invite_option')
diff --git a/resources/views/users/parts/language-option-row.blade.php b/resources/views/users/parts/language-option-row.blade.php
new file mode 100644 (file)
index 0000000..82907b5
--- /dev/null
@@ -0,0 +1,18 @@
+{{--
+$value - Currently selected lanuage value
+--}}
+<div class="grid half gap-xl v-center">
+    <div>
+        <label for="user-language" class="setting-list-label">{{ trans('settings.users_preferred_language') }}</label>
+        <p class="small">
+            {{ trans('settings.users_preferred_language_desc') }}
+        </p>
+    </div>
+    <div>
+        <select name="setting[language]" id="user-language">
+            @foreach(trans('settings.language_select') as $lang => $label)
+                <option @if($value === $lang) selected @endif value="{{ $lang }}">{{ $label }}</option>
+            @endforeach
+        </select>
+    </div>
+</div>
\ No newline at end of file
index 4028b5c1da731c45925d8d27caea334b46cc0e74..b59c80ec6a22c99ef23d210adec0ae9c741e1f05 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 
@@ -9,7 +9,7 @@
             <div>
                 <section id="recent-user-activity" class="mb-xl">
                     <h5>{{ trans('entities.recent_activity') }}</h5>
-                    @include('partials.activity-list', ['activity' => $activity])
+                    @include('common.activity-list', ['activity' => $activity])
                 </section>
             </div>
 
                     <h2 id="recent-pages" class="list-heading">
                         {{ trans('entities.recently_created_pages') }}
                         @if (count($recentlyCreated['pages']) > 0)
-                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->id.'} {type:page}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
+                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->slug.'} {type:page}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
                         @endif
                     </h2>
                     @if (count($recentlyCreated['pages']) > 0)
-                        @include('partials.entity-list', ['entities' => $recentlyCreated['pages'], 'showPath' => true])
+                        @include('entities.list', ['entities' => $recentlyCreated['pages'], 'showPath' => true])
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_pages', ['userName' => $user->name]) }}</p>
                     @endif
                     <h2 id="recent-chapters" class="list-heading">
                         {{ trans('entities.recently_created_chapters') }}
                         @if (count($recentlyCreated['chapters']) > 0)
-                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->id.'} {type:chapter}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
+                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->slug.'} {type:chapter}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
                         @endif
                     </h2>
                     @if (count($recentlyCreated['chapters']) > 0)
-                        @include('partials.entity-list', ['entities' => $recentlyCreated['chapters'], 'showPath' => true])
+                        @include('entities.list', ['entities' => $recentlyCreated['chapters'], 'showPath' => true])
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_chapters', ['userName' => $user->name]) }}</p>
                     @endif
                     <h2 id="recent-books" class="list-heading">
                         {{ trans('entities.recently_created_books') }}
                         @if (count($recentlyCreated['books']) > 0)
-                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->id.'} {type:book}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
+                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->slug.'} {type:book}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
                         @endif
                     </h2>
                     @if (count($recentlyCreated['books']) > 0)
-                        @include('partials.entity-list', ['entities' => $recentlyCreated['books'], 'showPath' => true])
+                        @include('entities.list', ['entities' => $recentlyCreated['books'], 'showPath' => true])
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_books', ['userName' => $user->name]) }}</p>
                     @endif
                     <h2 id="recent-shelves" class="list-heading">
                         {{ trans('entities.recently_created_shelves') }}
                         @if (count($recentlyCreated['shelves']) > 0)
-                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->id.'} {type:bookshelf}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
+                            <a href="{{ url('/search?term=' . urlencode('{created_by:'.$user->slug.'} {type:bookshelf}') ) }}" class="text-small ml-s">{{ trans('common.view_all') }}</a>
                         @endif
                     </h2>
                     @if (count($recentlyCreated['shelves']) > 0)
-                        @include('partials.entity-list', ['entities' => $recentlyCreated['shelves'], 'showPath' => true])
+                        @include('entities.list', ['entities' => $recentlyCreated['shelves'], 'showPath' => true])
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_shelves', ['userName' => $user->name]) }}</p>
                     @endif
index 063fbd72a964889a460f82fa6b8bc37040746b7f..cd8dd355a6f95629dc50abe2ee1e080314252771 100644 (file)
@@ -1,49 +1,70 @@
 <?php
 
+use BookStack\Http\Controllers\Api\ApiDocsController;
+use BookStack\Http\Controllers\Api\AttachmentApiController;
+use BookStack\Http\Controllers\Api\BookApiController;
+use BookStack\Http\Controllers\Api\BookExportApiController;
+use BookStack\Http\Controllers\Api\BookshelfApiController;
+use BookStack\Http\Controllers\Api\ChapterApiController;
+use BookStack\Http\Controllers\Api\ChapterExportApiController;
+use BookStack\Http\Controllers\Api\PageApiController;
+use BookStack\Http\Controllers\Api\PageExportApiController;
+use BookStack\Http\Controllers\Api\SearchApiController;
+use Illuminate\Support\Facades\Route;
+
 /**
  * Routes for the BookStack API.
  * Routes have a uri prefix of /api/.
- * Controllers are all within app/Http/Controllers/Api
+ * Controllers are all within app/Http/Controllers/Api.
  */
+Route::get('docs.json', [ApiDocsController::class, 'json']);
+
+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', [BookApiController::class, 'list']);
+Route::post('books', [BookApiController::class, 'create']);
+Route::get('books/{id}', [BookApiController::class, 'read']);
+Route::put('books/{id}', [BookApiController::class, 'update']);
+Route::delete('books/{id}', [BookApiController::class, 'delete']);
+
+Route::get('books/{id}/export/html', [BookExportApiController::class, 'exportHtml']);
+Route::get('books/{id}/export/pdf', [BookExportApiController::class, 'exportPdf']);
+Route::get('books/{id}/export/plaintext', [BookExportApiController::class, 'exportPlainText']);
+Route::get('books/{id}/export/markdown', [BookExportApiController::class, 'exportMarkdown']);
+
+Route::get('chapters', [ChapterApiController::class, 'list']);
+Route::post('chapters', [ChapterApiController::class, 'create']);
+Route::get('chapters/{id}', [ChapterApiController::class, 'read']);
+Route::put('chapters/{id}', [ChapterApiController::class, 'update']);
+Route::delete('chapters/{id}', [ChapterApiController::class, 'delete']);
+
+Route::get('chapters/{id}/export/html', [ChapterExportApiController::class, 'exportHtml']);
+Route::get('chapters/{id}/export/pdf', [ChapterExportApiController::class, 'exportPdf']);
+Route::get('chapters/{id}/export/plaintext', [ChapterExportApiController::class, 'exportPlainText']);
+Route::get('chapters/{id}/export/markdown', [ChapterExportApiController::class, 'exportMarkdown']);
+
+Route::get('pages', [PageApiController::class, 'list']);
+Route::post('pages', [PageApiController::class, 'create']);
+Route::get('pages/{id}', [PageApiController::class, 'read']);
+Route::put('pages/{id}', [PageApiController::class, 'update']);
+Route::delete('pages/{id}', [PageApiController::class, 'delete']);
+
+Route::get('pages/{id}/export/html', [PageExportApiController::class, 'exportHtml']);
+Route::get('pages/{id}/export/pdf', [PageExportApiController::class, 'exportPdf']);
+Route::get('pages/{id}/export/plaintext', [PageExportApiController::class, 'exportPlainText']);
+Route::get('pages/{id}/export/markdown', [PageExportApiController::class, 'exportMarkDown']);
+
+Route::get('search', [SearchApiController::class, 'all']);
 
-Route::get('docs', 'ApiDocsController@display');
-Route::get('docs.json', 'ApiDocsController@json');
-
-Route::get('books', 'BookApiController@list');
-Route::post('books', 'BookApiController@create');
-Route::get('books/{id}', 'BookApiController@read');
-Route::put('books/{id}', 'BookApiController@update');
-Route::delete('books/{id}', 'BookApiController@delete');
-
-Route::get('books/{id}/export/html', 'BookExportApiController@exportHtml');
-Route::get('books/{id}/export/pdf', 'BookExportApiController@exportPdf');
-Route::get('books/{id}/export/plaintext', 'BookExportApiController@exportPlainText');
-
-Route::get('chapters', 'ChapterApiController@list');
-Route::post('chapters', 'ChapterApiController@create');
-Route::get('chapters/{id}', 'ChapterApiController@read');
-Route::put('chapters/{id}', 'ChapterApiController@update');
-Route::delete('chapters/{id}', 'ChapterApiController@delete');
-
-Route::get('chapters/{id}/export/html', 'ChapterExportApiController@exportHtml');
-Route::get('chapters/{id}/export/pdf', 'ChapterExportApiController@exportPdf');
-Route::get('chapters/{id}/export/plaintext', 'ChapterExportApiController@exportPlainText');
-
-Route::get('pages', 'PageApiController@list');
-Route::post('pages', 'PageApiController@create');
-Route::get('pages/{id}', 'PageApiController@read');
-Route::put('pages/{id}', 'PageApiController@update');
-Route::delete('pages/{id}', 'PageApiController@delete');
-
-Route::get('pages/{id}/export/html', 'PageExportApiController@exportHtml');
-Route::get('pages/{id}/export/pdf', 'PageExportApiController@exportPdf');
-Route::get('pages/{id}/export/plaintext', 'PageExportApiController@exportPlainText');
-
-Route::get('shelves', 'BookshelfApiController@list');
-Route::post('shelves', 'BookshelfApiController@create');
-Route::get('shelves/{id}', 'BookshelfApiController@read');
-Route::put('shelves/{id}', 'BookshelfApiController@update');
-Route::delete('shelves/{id}', 'BookshelfApiController@delete');
+Route::get('shelves', [BookshelfApiController::class, 'list']);
+Route::post('shelves', [BookshelfApiController::class, 'create']);
+Route::get('shelves/{id}', [BookshelfApiController::class, 'read']);
+Route::put('shelves/{id}', [BookshelfApiController::class, 'update']);
+Route::delete('shelves/{id}', [BookshelfApiController::class, 'delete']);
 
 Route::get('users', 'UserApiController@list');
-Route::get('users/{id}', 'UserApiController@read');
+Route::get('users/{id}', 'UserApiController@read');
\ No newline at end of file
diff --git a/routes/console.php b/routes/console.php
new file mode 100644 (file)
index 0000000..e05f4c9
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+use Illuminate\Foundation\Inspiring;
+use Illuminate\Support\Facades\Artisan;
+
+/*
+|--------------------------------------------------------------------------
+| Console Routes
+|--------------------------------------------------------------------------
+|
+| This file is where you may define all of your Closure based console
+| commands. Each Closure is bound to a command instance allowing a
+| simple approach to interacting with each command's IO methods.
+|
+*/
+
+Artisan::command('inspire', function () {
+    $this->comment(Inspiring::quote());
+})->purpose('Display an inspiring quote');
index 9d482dc41a8e52bfc842eeca7a5cc364b759d44e..73cc3dc66c20522a160eb470c4020b79de5971c8 100644 (file)
 <?php
 
-Route::get('/status', 'StatusController@show');
-Route::get('/robots.txt', 'HomeController@getRobots');
+use BookStack\Http\Controllers\Api;
+use BookStack\Http\Controllers\AttachmentController;
+use BookStack\Http\Controllers\AuditLogController;
+use BookStack\Http\Controllers\Auth;
+use BookStack\Http\Controllers\BookController;
+use BookStack\Http\Controllers\BookExportController;
+use BookStack\Http\Controllers\BookshelfController;
+use BookStack\Http\Controllers\BookSortController;
+use BookStack\Http\Controllers\ChapterController;
+use BookStack\Http\Controllers\ChapterExportController;
+use BookStack\Http\Controllers\CommentController;
+use BookStack\Http\Controllers\FavouriteController;
+use BookStack\Http\Controllers\HomeController;
+use BookStack\Http\Controllers\Images;
+use BookStack\Http\Controllers\MaintenanceController;
+use BookStack\Http\Controllers\PageController;
+use BookStack\Http\Controllers\PageExportController;
+use BookStack\Http\Controllers\PageRevisionController;
+use BookStack\Http\Controllers\PageTemplateController;
+use BookStack\Http\Controllers\RecycleBinController;
+use BookStack\Http\Controllers\RoleController;
+use BookStack\Http\Controllers\SearchController;
+use BookStack\Http\Controllers\SettingController;
+use BookStack\Http\Controllers\StatusController;
+use BookStack\Http\Controllers\TagController;
+use BookStack\Http\Controllers\UserApiTokenController;
+use BookStack\Http\Controllers\UserController;
+use BookStack\Http\Controllers\UserProfileController;
+use BookStack\Http\Controllers\UserSearchController;
+use BookStack\Http\Controllers\WebhookController;
+use BookStack\Http\Middleware\VerifyCsrfToken;
+use Illuminate\Session\Middleware\StartSession;
+use Illuminate\Support\Facades\Route;
+use Illuminate\View\Middleware\ShareErrorsFromSession;
+
+Route::get('/status', [StatusController::class, 'show']);
+Route::get('/robots.txt', [HomeController::class, 'robots']);
 
 // Authenticated routes...
-Route::group(['middleware' => 'auth'], function () {
+Route::middleware('auth')->group(function () {
 
     // Secure images routing
-    Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
+    Route::get('/uploads/images/{path}', [Images\ImageController::class, 'showImage'])
         ->where('path', '.*$');
 
-    Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
+    // API docs routes
+    Route::redirect('/api', '/api/docs');
+    Route::get('/api/docs', [Api\ApiDocsController::class, 'display']);
+
+    Route::get('/pages/recently-updated', [PageController::class, 'showRecentlyUpdated']);
 
     // Shelves
-    Route::get('/create-shelf', 'BookshelfController@create');
-    Route::group(['prefix' => 'shelves'], function() {
-        Route::get('/', 'BookshelfController@index');
-        Route::post('/', 'BookshelfController@store');
-        Route::get('/{slug}/edit', 'BookshelfController@edit');
-        Route::get('/{slug}/delete', 'BookshelfController@showDelete');
-        Route::get('/{slug}', 'BookshelfController@show');
-        Route::put('/{slug}', 'BookshelfController@update');
-        Route::delete('/{slug}', 'BookshelfController@destroy');
-        Route::get('/{slug}/permissions', 'BookshelfController@showPermissions');
-        Route::put('/{slug}/permissions', 'BookshelfController@permissions');
-        Route::post('/{slug}/copy-permissions', 'BookshelfController@copyPermissions');
-
-        Route::get('/{shelfSlug}/create-book', 'BookController@create');
-        Route::post('/{shelfSlug}/create-book', 'BookController@store');
-    });
-
-    Route::get('/create-book', 'BookController@create');
-    Route::group(['prefix' => 'books'], function () {
-
-        // Books
-        Route::get('/', 'BookController@index');
-        Route::post('/', 'BookController@store');
-        Route::get('/{slug}/edit', 'BookController@edit');
-        Route::put('/{slug}', 'BookController@update');
-        Route::delete('/{id}', 'BookController@destroy');
-        Route::get('/{slug}/sort-item', 'BookSortController@showItem');
-        Route::get('/{slug}', 'BookController@show');
-        Route::get('/{bookSlug}/permissions', 'BookController@showPermissions');
-        Route::put('/{bookSlug}/permissions', 'BookController@permissions');
-        Route::get('/{slug}/delete', 'BookController@showDelete');
-        Route::get('/{bookSlug}/sort', 'BookSortController@show');
-        Route::put('/{bookSlug}/sort', 'BookSortController@update');
-        Route::get('/{bookSlug}/export/html', 'BookExportController@html');
-        Route::get('/{bookSlug}/export/pdf', 'BookExportController@pdf');
-        Route::get('/{bookSlug}/export/plaintext', 'BookExportController@plainText');
-
-        // Pages
-        Route::get('/{bookSlug}/create-page', 'PageController@create');
-        Route::post('/{bookSlug}/create-guest-page', 'PageController@createAsGuest');
-        Route::get('/{bookSlug}/draft/{pageId}', 'PageController@editDraft');
-        Route::post('/{bookSlug}/draft/{pageId}', 'PageController@store');
-        Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
-        Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageExportController@pdf');
-        Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageExportController@html');
-        Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageExportController@plainText');
-        Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
-        Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
-        Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move');
-        Route::get('/{bookSlug}/page/{pageSlug}/copy', 'PageController@showCopy');
-        Route::post('/{bookSlug}/page/{pageSlug}/copy', 'PageController@copy');
-        Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
-        Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
-        Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showPermissions');
-        Route::put('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@permissions');
-        Route::put('/{bookSlug}/page/{pageSlug}', 'PageController@update');
-        Route::delete('/{bookSlug}/page/{pageSlug}', 'PageController@destroy');
-        Route::delete('/{bookSlug}/draft/{pageId}', 'PageController@destroyDraft');
-
-        // Revisions
-        Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageRevisionController@index');
-        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageRevisionController@show');
-        Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageRevisionController@changes');
-        Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageRevisionController@restore');
-        Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageRevisionController@destroy');
-
-        // Chapters
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create');
-        Route::post('/{bookSlug}/chapter/{chapterSlug}/create-guest-page', 'PageController@createAsGuest');
-        Route::get('/{bookSlug}/create-chapter', 'ChapterController@create');
-        Route::post('/{bookSlug}/create-chapter', 'ChapterController@store');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
-        Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove');
-        Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showPermissions');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/export/pdf', 'ChapterExportController@pdf');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/export/html', 'ChapterExportController@html');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/export/plaintext', 'ChapterExportController@plainText');
-        Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@permissions');
-        Route::get('/{bookSlug}/chapter/{chapterSlug}/delete', 'ChapterController@showDelete');
-        Route::delete('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@destroy');
-    });
+    Route::get('/create-shelf', [BookshelfController::class, 'create']);
+    Route::get('/shelves/', [BookshelfController::class, 'index']);
+    Route::post('/shelves/', [BookshelfController::class, 'store']);
+    Route::get('/shelves/{slug}/edit', [BookshelfController::class, 'edit']);
+    Route::get('/shelves/{slug}/delete', [BookshelfController::class, 'showDelete']);
+    Route::get('/shelves/{slug}', [BookshelfController::class, 'show']);
+    Route::put('/shelves/{slug}', [BookshelfController::class, 'update']);
+    Route::delete('/shelves/{slug}', [BookshelfController::class, 'destroy']);
+    Route::get('/shelves/{slug}/permissions', [BookshelfController::class, 'showPermissions']);
+    Route::put('/shelves/{slug}/permissions', [BookshelfController::class, 'permissions']);
+    Route::post('/shelves/{slug}/copy-permissions', [BookshelfController::class, 'copyPermissions']);
+
+    // Book Creation
+    Route::get('/shelves/{shelfSlug}/create-book', [BookController::class, 'create']);
+    Route::post('/shelves/{shelfSlug}/create-book', [BookController::class, 'store']);
+    Route::get('/create-book', [BookController::class, 'create']);
+
+    // Books
+    Route::get('/books/', [BookController::class, 'index']);
+    Route::post('/books/', [BookController::class, 'store']);
+    Route::get('/books/{slug}/edit', [BookController::class, 'edit']);
+    Route::put('/books/{slug}', [BookController::class, 'update']);
+    Route::delete('/books/{id}', [BookController::class, 'destroy']);
+    Route::get('/books/{slug}/sort-item', [BookSortController::class, 'showItem']);
+    Route::get('/books/{slug}', [BookController::class, 'show']);
+    Route::get('/books/{bookSlug}/permissions', [BookController::class, 'showPermissions']);
+    Route::put('/books/{bookSlug}/permissions', [BookController::class, 'permissions']);
+    Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']);
+    Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']);
+    Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']);
+    Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']);
+    Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']);
+    Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/export/pdf', [BookExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/export/markdown', [BookExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/export/zip', [BookExportController::class, 'zip']);
+    Route::get('/books/{bookSlug}/export/plaintext', [BookExportController::class, 'plainText']);
+
+    // Pages
+    Route::get('/books/{bookSlug}/create-page', [PageController::class, 'create']);
+    Route::post('/books/{bookSlug}/create-guest-page', [PageController::class, 'createAsGuest']);
+    Route::get('/books/{bookSlug}/draft/{pageId}', [PageController::class, 'editDraft']);
+    Route::post('/books/{bookSlug}/draft/{pageId}', [PageController::class, 'store']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'show']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [PageExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [PageExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [PageExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [PageExportController::class, 'plainText']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [PageController::class, 'edit']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/move', [PageController::class, 'showMove']);
+    Route::put('/books/{bookSlug}/page/{pageSlug}/move', [PageController::class, 'move']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/copy', [PageController::class, 'showCopy']);
+    Route::post('/books/{bookSlug}/page/{pageSlug}/copy', [PageController::class, 'copy']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/delete', [PageController::class, 'showDelete']);
+    Route::get('/books/{bookSlug}/draft/{pageId}/delete', [PageController::class, 'showDeleteDraft']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/permissions', [PageController::class, 'showPermissions']);
+    Route::put('/books/{bookSlug}/page/{pageSlug}/permissions', [PageController::class, 'permissions']);
+    Route::put('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'update']);
+    Route::delete('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'destroy']);
+    Route::delete('/books/{bookSlug}/draft/{pageId}', [PageController::class, 'destroyDraft']);
+
+    // Revisions
+    Route::get('/books/{bookSlug}/page/{pageSlug}/revisions', [PageRevisionController::class, 'index']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}', [PageRevisionController::class, 'show']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', [PageRevisionController::class, 'changes']);
+    Route::put('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', [PageRevisionController::class, 'restore']);
+    Route::delete('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', [PageRevisionController::class, 'destroy']);
+
+    // Chapters
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/create-page', [PageController::class, 'create']);
+    Route::post('/books/{bookSlug}/chapter/{chapterSlug}/create-guest-page', [PageController::class, 'createAsGuest']);
+    Route::get('/books/{bookSlug}/create-chapter', [ChapterController::class, 'create']);
+    Route::post('/books/{bookSlug}/create-chapter', [ChapterController::class, 'store']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'show']);
+    Route::put('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'update']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'showMove']);
+    Route::put('/books/{bookSlug}/chapter/{chapterSlug}/move', [ChapterController::class, 'move']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'showCopy']);
+    Route::post('/books/{bookSlug}/chapter/{chapterSlug}/copy', [ChapterController::class, 'copy']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [ChapterController::class, 'edit']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'showPermissions']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ChapterExportController::class, 'pdf']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ChapterExportController::class, 'html']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ChapterExportController::class, 'markdown']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ChapterExportController::class, 'plainText']);
+    Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'permissions']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [ChapterController::class, 'showDelete']);
+    Route::delete('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'destroy']);
 
     // User Profile routes
-    Route::get('/user/{slug}', 'UserProfileController@show');
+    Route::get('/user/{slug}', [UserProfileController::class, 'show']);
 
     // Image routes
-    Route::get('/images/gallery', 'Images\GalleryImageController@list');
-    Route::post('/images/gallery', 'Images\GalleryImageController@create');
-    Route::get('/images/drawio', 'Images\DrawioImageController@list');
-    Route::get('/images/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64');
-    Route::post('/images/drawio', 'Images\DrawioImageController@create');
-    Route::get('/images/edit/{id}', 'Images\ImageController@edit');
-    Route::put('/images/{id}', 'Images\ImageController@update');
-    Route::delete('/images/{id}', 'Images\ImageController@destroy');
+    Route::get('/images/gallery', [Images\GalleryImageController::class, 'list']);
+    Route::post('/images/gallery', [Images\GalleryImageController::class, 'create']);
+    Route::get('/images/drawio', [Images\DrawioImageController::class, 'list']);
+    Route::get('/images/drawio/base64/{id}', [Images\DrawioImageController::class, 'getAsBase64']);
+    Route::post('/images/drawio', [Images\DrawioImageController::class, 'create']);
+    Route::get('/images/edit/{id}', [Images\ImageController::class, 'edit']);
+    Route::put('/images/{id}', [Images\ImageController::class, 'update']);
+    Route::delete('/images/{id}', [Images\ImageController::class, 'destroy']);
 
     // Attachments routes
-    Route::get('/attachments/{id}', 'AttachmentController@get');
-    Route::post('/attachments/upload', 'AttachmentController@upload');
-    Route::post('/attachments/upload/{id}', 'AttachmentController@uploadUpdate');
-    Route::post('/attachments/link', 'AttachmentController@attachLink');
-    Route::put('/attachments/{id}', 'AttachmentController@update');
-    Route::get('/attachments/edit/{id}', 'AttachmentController@getUpdateForm');
-    Route::get('/attachments/get/page/{pageId}', 'AttachmentController@listForPage');
-    Route::put('/attachments/sort/page/{pageId}', 'AttachmentController@sortForPage');
-    Route::delete('/attachments/{id}', 'AttachmentController@delete');
+    Route::get('/attachments/{id}', [AttachmentController::class, 'get']);
+    Route::post('/attachments/upload', [AttachmentController::class, 'upload']);
+    Route::post('/attachments/upload/{id}', [AttachmentController::class, 'uploadUpdate']);
+    Route::post('/attachments/link', [AttachmentController::class, 'attachLink']);
+    Route::put('/attachments/{id}', [AttachmentController::class, 'update']);
+    Route::get('/attachments/edit/{id}', [AttachmentController::class, 'getUpdateForm']);
+    Route::get('/attachments/get/page/{pageId}', [AttachmentController::class, 'listForPage']);
+    Route::put('/attachments/sort/page/{pageId}', [AttachmentController::class, 'sortForPage']);
+    Route::delete('/attachments/{id}', [AttachmentController::class, 'delete']);
 
     // AJAX routes
-    Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
-    Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
-    Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
+    Route::put('/ajax/page/{id}/save-draft', [PageController::class, 'saveDraft']);
+    Route::get('/ajax/page/{id}', [PageController::class, 'getPageAjax']);
+    Route::delete('/ajax/page/{id}', [PageController::class, 'ajaxDestroy']);
 
-    // Tag routes (AJAX)
-    Route::group(['prefix' => 'ajax/tags'], function () {
-        Route::get('/suggest/names', 'TagController@getNameSuggestions');
-        Route::get('/suggest/values', 'TagController@getValueSuggestions');
-    });
+    // Tag routes
+    Route::get('/tags', [TagController::class, 'index']);
+    Route::get('/ajax/tags/suggest/names', [TagController::class, 'getNameSuggestions']);
+    Route::get('/ajax/tags/suggest/values', [TagController::class, 'getValueSuggestions']);
 
-    Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
+    Route::get('/ajax/search/entities', [SearchController::class, 'searchEntitiesAjax']);
 
     // Comments
-    Route::post('/comment/{pageId}', 'CommentController@savePageComment');
-    Route::put('/comment/{id}', 'CommentController@update');
-    Route::delete('/comment/{id}', 'CommentController@destroy');
+    Route::post('/comment/{pageId}', [CommentController::class, 'savePageComment']);
+    Route::put('/comment/{id}', [CommentController::class, 'update']);
+    Route::delete('/comment/{id}', [CommentController::class, 'destroy']);
 
     // Links
-    Route::get('/link/{id}', 'PageController@redirectFromLink');
+    Route::get('/link/{id}', [PageController::class, 'redirectFromLink']);
 
     // Search
-    Route::get('/search', 'SearchController@search');
-    Route::get('/search/book/{bookId}', 'SearchController@searchBook');
-    Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter');
-    Route::get('/search/entity/siblings', 'SearchController@searchSiblings');
+    Route::get('/search', [SearchController::class, 'search']);
+    Route::get('/search/book/{bookId}', [SearchController::class, 'searchBook']);
+    Route::get('/search/chapter/{bookId}', [SearchController::class, 'searchChapter']);
+    Route::get('/search/entity/siblings', [SearchController::class, 'searchSiblings']);
 
     // User Search
-    Route::get('/search/users/select', 'UserSearchController@forSelect');
+    Route::get('/search/users/select', [UserSearchController::class, 'forSelect']);
+
+    // Template System
+    Route::get('/templates', [PageTemplateController::class, 'list']);
+    Route::get('/templates/{templateId}', [PageTemplateController::class, 'get']);
 
-    Route::get('/templates', 'PageTemplateController@list');
-    Route::get('/templates/{templateId}', 'PageTemplateController@get');
+    // Favourites
+    Route::get('/favourites', [FavouriteController::class, 'index']);
+    Route::post('/favourites/add', [FavouriteController::class, 'add']);
+    Route::post('/favourites/remove', [FavouriteController::class, 'remove']);
 
     // Other Pages
-    Route::get('/', 'HomeController@index');
-    Route::get('/home', 'HomeController@index');
-    Route::get('/custom-head-content', 'HomeController@customHeadContent');
+    Route::get('/', [HomeController::class, 'index']);
+    Route::get('/home', [HomeController::class, 'index']);
+    Route::get('/custom-head-content', [HomeController::class, 'customHeadContent']);
 
     // Settings
-    Route::group(['prefix' => 'settings'], function() {
-        Route::get('/', 'SettingController@index')->name('settings');
-        Route::post('/', 'SettingController@update');
-
-        // Maintenance
-        Route::get('/maintenance', 'MaintenanceController@index');
-        Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
-        Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
-
-        // Recycle Bin
-        Route::get('/recycle-bin', 'RecycleBinController@index');
-        Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
-        Route::get('/recycle-bin/{id}/destroy', 'RecycleBinController@showDestroy');
-        Route::delete('/recycle-bin/{id}', 'RecycleBinController@destroy');
-        Route::get('/recycle-bin/{id}/restore', 'RecycleBinController@showRestore');
-        Route::post('/recycle-bin/{id}/restore', 'RecycleBinController@restore');
-
-        // Audit Log
-        Route::get('/audit', 'AuditLogController@index');
-
-        // Users
-        Route::get('/users', 'UserController@index');
-        Route::get('/users/create', 'UserController@create');
-        Route::get('/users/{id}/delete', 'UserController@delete');
-        Route::patch('/users/{id}/switch-books-view', 'UserController@switchBooksView');
-        Route::patch('/users/{id}/switch-shelves-view', 'UserController@switchShelvesView');
-        Route::patch('/users/{id}/switch-shelf-view', 'UserController@switchShelfView');
-        Route::patch('/users/{id}/change-sort/{type}', 'UserController@changeSort');
-        Route::patch('/users/{id}/update-expansion-preference/{key}', 'UserController@updateExpansionPreference');
-        Route::patch('/users/toggle-dark-mode', 'UserController@toggleDarkMode');
-        Route::post('/users/create', 'UserController@store');
-        Route::get('/users/{id}', 'UserController@edit');
-        Route::put('/users/{id}', 'UserController@update');
-        Route::delete('/users/{id}', 'UserController@destroy');
-
-        // User API Tokens
-        Route::get('/users/{userId}/create-api-token', 'UserApiTokenController@create');
-        Route::post('/users/{userId}/create-api-token', 'UserApiTokenController@store');
-        Route::get('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@edit');
-        Route::put('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@update');
-        Route::get('/users/{userId}/api-tokens/{tokenId}/delete', 'UserApiTokenController@delete');
-        Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy');
-
-        // Roles
-        Route::get('/roles', 'RoleController@list');
-        Route::get('/roles/new', 'RoleController@create');
-        Route::post('/roles/new', 'RoleController@store');
-        Route::get('/roles/delete/{id}', 'RoleController@showDelete');
-        Route::delete('/roles/delete/{id}', 'RoleController@delete');
-        Route::get('/roles/{id}', 'RoleController@edit');
-        Route::put('/roles/{id}', 'RoleController@update');
-    });
+    Route::get('/settings', [SettingController::class, 'index'])->name('settings');
+    Route::post('/settings', [SettingController::class, 'update']);
+
+    // Maintenance
+    Route::get('/settings/maintenance', [MaintenanceController::class, 'index']);
+    Route::delete('/settings/maintenance/cleanup-images', [MaintenanceController::class, 'cleanupImages']);
+    Route::post('/settings/maintenance/send-test-email', [MaintenanceController::class, 'sendTestEmail']);
+
+    // Recycle Bin
+    Route::get('/settings/recycle-bin', [RecycleBinController::class, 'index']);
+    Route::post('/settings/recycle-bin/empty', [RecycleBinController::class, 'empty']);
+    Route::get('/settings/recycle-bin/{id}/destroy', [RecycleBinController::class, 'showDestroy']);
+    Route::delete('/settings/recycle-bin/{id}', [RecycleBinController::class, 'destroy']);
+    Route::get('/settings/recycle-bin/{id}/restore', [RecycleBinController::class, 'showRestore']);
+    Route::post('/settings/recycle-bin/{id}/restore', [RecycleBinController::class, 'restore']);
+
+    // Audit Log
+    Route::get('/settings/audit', [AuditLogController::class, 'index']);
+
+    // Users
+    Route::get('/settings/users', [UserController::class, 'index']);
+    Route::get('/settings/users/create', [UserController::class, 'create']);
+    Route::get('/settings/users/{id}/delete', [UserController::class, 'delete']);
+    Route::patch('/settings/users/{id}/switch-books-view', [UserController::class, 'switchBooksView']);
+    Route::patch('/settings/users/{id}/switch-shelves-view', [UserController::class, 'switchShelvesView']);
+    Route::patch('/settings/users/{id}/switch-shelf-view', [UserController::class, 'switchShelfView']);
+    Route::patch('/settings/users/{id}/change-sort/{type}', [UserController::class, 'changeSort']);
+    Route::patch('/settings/users/{id}/update-expansion-preference/{key}', [UserController::class, 'updateExpansionPreference']);
+    Route::patch('/settings/users/toggle-dark-mode', [UserController::class, 'toggleDarkMode']);
+    Route::post('/settings/users/create', [UserController::class, 'store']);
+    Route::get('/settings/users/{id}', [UserController::class, 'edit']);
+    Route::put('/settings/users/{id}', [UserController::class, 'update']);
+    Route::delete('/settings/users/{id}', [UserController::class, 'destroy']);
+
+    // User API Tokens
+    Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']);
+    Route::post('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'store']);
+    Route::get('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'edit']);
+    Route::put('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'update']);
+    Route::get('/settings/users/{userId}/api-tokens/{tokenId}/delete', [UserApiTokenController::class, 'delete']);
+    Route::delete('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'destroy']);
+
+    // Roles
+    Route::get('/settings/roles', [RoleController::class, 'index']);
+    Route::get('/settings/roles/new', [RoleController::class, 'create']);
+    Route::post('/settings/roles/new', [RoleController::class, 'store']);
+    Route::get('/settings/roles/delete/{id}', [RoleController::class, 'showDelete']);
+    Route::delete('/settings/roles/delete/{id}', [RoleController::class, 'delete']);
+    Route::get('/settings/roles/{id}', [RoleController::class, 'edit']);
+    Route::put('/settings/roles/{id}', [RoleController::class, 'update']);
+
+    // Webhooks
+    Route::get('/settings/webhooks', [WebhookController::class, 'index']);
+    Route::get('/settings/webhooks/create', [WebhookController::class, 'create']);
+    Route::post('/settings/webhooks/create', [WebhookController::class, 'store']);
+    Route::get('/settings/webhooks/{id}', [WebhookController::class, 'edit']);
+    Route::put('/settings/webhooks/{id}', [WebhookController::class, 'update']);
+    Route::get('/settings/webhooks/{id}/delete', [WebhookController::class, 'delete']);
+    Route::delete('/settings/webhooks/{id}', [WebhookController::class, 'destroy']);
+});
 
+// MFA routes
+Route::middleware('mfa-setup')->group(function () {
+    Route::get('/mfa/setup', [Auth\MfaController::class, 'setup']);
+    Route::get('/mfa/totp/generate', [Auth\MfaTotpController::class, 'generate']);
+    Route::post('/mfa/totp/confirm', [Auth\MfaTotpController::class, 'confirm']);
+    Route::get('/mfa/backup_codes/generate', [Auth\MfaBackupCodesController::class, 'generate']);
+    Route::post('/mfa/backup_codes/confirm', [Auth\MfaBackupCodesController::class, 'confirm']);
 });
+Route::middleware('guest')->group(function () {
+    Route::get('/mfa/verify', [Auth\MfaController::class, 'verify']);
+    Route::post('/mfa/totp/verify', [Auth\MfaTotpController::class, 'verify']);
+    Route::post('/mfa/backup_codes/verify', [Auth\MfaBackupCodesController::class, 'verify']);
+});
+Route::delete('/mfa/{method}/remove', [Auth\MfaController::class, 'remove'])->middleware('auth');
 
 // Social auth routes
-Route::get('/login/service/{socialDriver}', 'Auth\SocialController@login');
-Route::get('/login/service/{socialDriver}/callback', 'Auth\SocialController@callback');
-Route::group(['middleware' => 'auth'], function () {
-    Route::get('/login/service/{socialDriver}/detach', 'Auth\SocialController@detach');
-});
-Route::get('/register/service/{socialDriver}', 'Auth\SocialController@register');
+Route::get('/login/service/{socialDriver}', [Auth\SocialController::class, 'login']);
+Route::get('/login/service/{socialDriver}/callback', [Auth\SocialController::class, 'callback']);
+Route::post('/login/service/{socialDriver}/detach', [Auth\SocialController::class, 'detach'])->middleware('auth');
+Route::get('/register/service/{socialDriver}', [Auth\SocialController::class, 'register']);
 
 // Login/Logout routes
-Route::get('/login', 'Auth\LoginController@getLogin');
-Route::post('/login', 'Auth\LoginController@login');
-Route::get('/logout', 'Auth\LoginController@logout');
-Route::get('/register', 'Auth\RegisterController@getRegister');
-Route::get('/register/confirm', 'Auth\ConfirmEmailController@show');
-Route::get('/register/confirm/awaiting', 'Auth\ConfirmEmailController@showAwaiting');
-Route::post('/register/confirm/resend', 'Auth\ConfirmEmailController@resend');
-Route::get('/register/confirm/{token}', 'Auth\ConfirmEmailController@confirm');
-Route::post('/register', 'Auth\RegisterController@postRegister');
+Route::get('/login', [Auth\LoginController::class, 'getLogin']);
+Route::post('/login', [Auth\LoginController::class, 'login']);
+Route::post('/logout', [Auth\LoginController::class, 'logout']);
+Route::get('/register', [Auth\RegisterController::class, 'getRegister']);
+Route::get('/register/confirm', [Auth\ConfirmEmailController::class, 'show']);
+Route::get('/register/confirm/awaiting', [Auth\ConfirmEmailController::class, 'showAwaiting']);
+Route::post('/register/confirm/resend', [Auth\ConfirmEmailController::class, 'resend']);
+Route::get('/register/confirm/{token}', [Auth\ConfirmEmailController::class, 'confirm']);
+Route::post('/register', [Auth\RegisterController::class, 'postRegister']);
 
 // SAML routes
-Route::post('/saml2/login', 'Auth\Saml2Controller@login');
-Route::get('/saml2/logout', 'Auth\Saml2Controller@logout');
-Route::get('/saml2/metadata', 'Auth\Saml2Controller@metadata');
-Route::get('/saml2/sls', 'Auth\Saml2Controller@sls');
-Route::post('/saml2/acs', 'Auth\Saml2Controller@acs');
+Route::post('/saml2/login', [Auth\Saml2Controller::class, 'login']);
+Route::post('/saml2/logout', [Auth\Saml2Controller::class, 'logout']);
+Route::get('/saml2/metadata', [Auth\Saml2Controller::class, 'metadata']);
+Route::get('/saml2/sls', [Auth\Saml2Controller::class, 'sls']);
+Route::post('/saml2/acs', [Auth\Saml2Controller::class, 'startAcs'])->withoutMiddleware([
+    StartSession::class,
+    ShareErrorsFromSession::class,
+    VerifyCsrfToken::class,
+]);
+Route::get('/saml2/acs', [Auth\Saml2Controller::class, 'processAcs']);
+
+// OIDC routes
+Route::post('/oidc/login', [Auth\OidcController::class, 'login']);
+Route::get('/oidc/callback', [Auth\OidcController::class, 'callback']);
 
 // User invitation routes
-Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword');
-Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword');
+Route::get('/register/invite/{token}', [Auth\UserInviteController::class, 'showSetPassword']);
+Route::post('/register/invite/{token}', [Auth\UserInviteController::class, 'setPassword']);
 
 // Password reset link request routes...
-Route::get('/password/email', 'Auth\ForgotPasswordController@showLinkRequestForm');
-Route::post('/password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail');
+Route::get('/password/email', [Auth\ForgotPasswordController::class, 'showLinkRequestForm']);
+Route::post('/password/email', [Auth\ForgotPasswordController::class, 'sendResetLinkEmail']);
 
 // Password reset routes...
-Route::get('/password/reset/{token}', 'Auth\ResetPasswordController@showResetForm');
-Route::post('/password/reset', 'Auth\ResetPasswordController@reset');
+Route::get('/password/reset/{token}', [Auth\ResetPasswordController::class, 'showResetForm']);
+Route::post('/password/reset', [Auth\ResetPasswordController::class, 'reset']);
 
-Route::fallback('HomeController@getNotFound');
\ No newline at end of file
+Route::fallback([HomeController::class, 'notFound'])->name('fallback');
index f65c7c444f2bf9108a1df96408008ebd25e3c18f..de038652fe43b216e36f61173647792021f0d168 100644 (file)
@@ -1,12 +1,10 @@
 <?php
 
 /**
- * Laravel - A PHP Framework For Web Artisans
+ * Laravel - A PHP Framework For Web Artisans.
  *
- * @package  Laravel
  * @author   Taylor Otwell <[email protected]>
  */
-
 $uri = urldecode(
     parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)
 );
@@ -14,8 +12,8 @@ $uri = urldecode(
 // This file allows us to emulate Apache's "mod_rewrite" functionality from the
 // built-in PHP web server. This provides a convenient way to test a Laravel
 // application without having installed a "real" web server software here.
-if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) {
+if ($uri !== '/' && file_exists(__DIR__ . '/public' . $uri)) {
     return false;
 }
 
-require_once __DIR__.'/public/index.php';
+require_once __DIR__ . '/public/index.php';
diff --git a/storage/clockwork/.gitignore b/storage/clockwork/.gitignore
new file mode 100644 (file)
index 0000000..3fac1bf
--- /dev/null
@@ -0,0 +1,3 @@
+*.json
+*.json.gz
+index
index 953edb7a993a821605b5c040962369e7f46c9e3a..05c4471f2b53fc17d3cac9d3d252755a35479f7c 100755 (executable)
@@ -1,7 +1,9 @@
-config.php
-routes.php
 compiled.php
-services.json
+config.php
+down
 events.scanned.php
+maintenance.php
+routes.php
 routes.scanned.php
-down
+schedule-*
+services.json
similarity index 54%
rename from tests/AuditLogTest.php
rename to tests/Actions/AuditLogTest.php
index 55a458786b2e2f4b4f7eacc56d8c089c65b80192..8266fd972f1abc540c40baa45a7d4336b56c8c78 100644 (file)
@@ -1,24 +1,29 @@
-<?php namespace Tests;
+<?php
 
+namespace Tests\Actions;
+
+use function app;
 use BookStack\Actions\Activity;
-use BookStack\Actions\ActivityService;
+use BookStack\Actions\ActivityLogger;
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\UserRepo;
 use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Tools\TrashCan;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\TrashCan;
 use Carbon\Carbon;
+use function config;
+use Tests\TestCase;
 
 class AuditLogTest extends TestCase
 {
-    /** @var ActivityService  */
+    /** @var ActivityLogger */
     protected $activityService;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
-        $this->activityService = app(ActivityService::class);
+        $this->activityService = app(ActivityLogger::class);
     }
 
     public function test_only_accessible_with_right_permissions()
@@ -44,7 +49,7 @@ class AuditLogTest extends TestCase
         $admin = $this->getAdmin();
         $this->actingAs($admin);
         $page = Page::query()->first();
-        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
         $activity = Activity::query()->orderBy('id', 'desc')->first();
 
         $resp = $this->get('settings/audit');
@@ -56,10 +61,10 @@ class AuditLogTest extends TestCase
 
     public function test_shows_name_for_deleted_items()
     {
-        $this->actingAs( $this->getAdmin());
+        $this->actingAs($this->getAdmin());
         $page = Page::query()->first();
         $pageName = $page->name;
-        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
 
         app(PageRepo::class)->destroy($page);
         app(TrashCan::class)->empty();
@@ -74,7 +79,7 @@ class AuditLogTest extends TestCase
         $viewer = $this->getViewer();
         $this->actingAs($viewer);
         $page = Page::query()->first();
-        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
 
         $this->actingAs($this->getAdmin());
         app(UserRepo::class)->destroy($viewer);
@@ -87,7 +92,7 @@ class AuditLogTest extends TestCase
     {
         $this->actingAs($this->getAdmin());
         $page = Page::query()->first();
-        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
 
         $resp = $this->get('settings/audit');
         $resp->assertSeeText($page->name);
@@ -100,7 +105,7 @@ class AuditLogTest extends TestCase
     {
         $this->actingAs($this->getAdmin());
         $page = Page::query()->first();
-        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
 
         $yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
         $tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
@@ -124,11 +129,11 @@ class AuditLogTest extends TestCase
         $editor = $this->getEditor();
         $this->actingAs($admin);
         $page = Page::query()->first();
-        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $this->activityService->add(ActivityType::PAGE_CREATE, $page);
 
         $this->actingAs($editor);
         $chapter = Chapter::query()->first();
-        $this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
+        $this->activityService->add(ActivityType::CHAPTER_UPDATE, $chapter);
 
         $resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id);
         $resp->assertSeeText($page->name);
@@ -137,7 +142,80 @@ class AuditLogTest extends TestCase
         $resp = $this->actingAs($admin)->get('settings/audit?user=' . $editor->id);
         $resp->assertSeeText($chapter->name);
         $resp->assertDontSeeText($page->name);
+    }
+
+    public function test_ip_address_logged_and_visible()
+    {
+        config()->set('app.proxies', '*');
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($editor)->put($page->getUrl(), [
+            'name' => 'Updated page',
+            'html' => '<p>Updated content</p>',
+        ], [
+            'X-Forwarded-For' => '192.123.45.1',
+        ])->assertRedirect($page->refresh()->getUrl());
+
+        $this->assertDatabaseHas('activities', [
+            'type'      => ActivityType::PAGE_UPDATE,
+            'ip'        => '192.123.45.1',
+            'user_id'   => $editor->id,
+            'entity_id' => $page->id,
+        ]);
+
+        $resp = $this->asAdmin()->get('/settings/audit');
+        $resp->assertSee('192.123.45.1');
+    }
+
+    public function test_ip_address_is_searchable()
+    {
+        config()->set('app.proxies', '*');
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
+        $this->actingAs($editor)->put($page->getUrl(), [
+            'name' => 'Updated page',
+            'html' => '<p>Updated content</p>',
+        ], [
+            'X-Forwarded-For' => '192.123.45.1',
+        ])->assertRedirect($page->refresh()->getUrl());
+
+        $this->actingAs($editor)->put($page->getUrl(), [
+            'name' => 'Updated page',
+            'html' => '<p>Updated content</p>',
+        ], [
+            'X-Forwarded-For' => '192.122.45.1',
+        ])->assertRedirect($page->refresh()->getUrl());
+
+        $resp = $this->asAdmin()->get('/settings/audit?&ip=192.123');
+        $resp->assertSee('192.123.45.1');
+        $resp->assertDontSee('192.122.45.1');
     }
 
-}
\ No newline at end of file
+    public function test_ip_address_not_logged_in_demo_mode()
+    {
+        config()->set('app.proxies', '*');
+        config()->set('app.env', 'demo');
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($editor)->put($page->getUrl(), [
+            'name' => 'Updated page',
+            'html' => '<p>Updated content</p>',
+        ], [
+            'X-Forwarded-For' => '192.123.45.1',
+            'REMOTE_ADDR'     => '192.123.45.2',
+        ])->assertRedirect($page->refresh()->getUrl());
+
+        $this->assertDatabaseHas('activities', [
+            'type'      => ActivityType::PAGE_UPDATE,
+            'ip'        => '127.0.0.1',
+            'user_id'   => $editor->id,
+            'entity_id' => $page->id,
+        ]);
+    }
+}
diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php
new file mode 100644 (file)
index 0000000..d9f9dda
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+
+namespace Tests\Actions;
+
+use BookStack\Actions\ActivityLogger;
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\DispatchWebhookJob;
+use BookStack\Actions\Webhook;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Page;
+use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Http;
+use Tests\TestCase;
+
+class WebhookCallTest extends TestCase
+{
+    public function test_webhook_listening_to_all_called_on_event()
+    {
+        $this->newWebhook([], ['all']);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_CREATE);
+        Bus::assertDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_webhook_listening_to_specific_event_called_on_event()
+    {
+        $this->newWebhook([], [ActivityType::ROLE_UPDATE]);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_UPDATE);
+        Bus::assertDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_webhook_listening_to_specific_event_not_called_on_other_event()
+    {
+        $this->newWebhook([], [ActivityType::ROLE_UPDATE]);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_CREATE);
+        Bus::assertNotDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_inactive_webhook_not_called_on_event()
+    {
+        $this->newWebhook(['active' => false], ['all']);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_CREATE);
+        Bus::assertNotDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_failed_webhook_call_logs_error()
+    {
+        $logger = $this->withTestLogger();
+        Http::fake([
+            '*' => Http::response('', 500),
+        ]);
+        $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
+        $this->assertNull($webhook->last_errored_at);
+
+        $this->runEvent(ActivityType::ROLE_CREATE);
+
+        $this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with status 500'));
+
+        $webhook->refresh();
+        $this->assertEquals('Response status from endpoint was 500', $webhook->last_error);
+        $this->assertNotNull($webhook->last_errored_at);
+    }
+
+    public function test_webhook_call_exception_is_caught_and_logged()
+    {
+        Http::shouldReceive('asJson')->andThrow(new \Exception('Failed to perform request'));
+
+        $logger = $this->withTestLogger();
+        $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
+        $this->assertNull($webhook->last_errored_at);
+
+        $this->runEvent(ActivityType::ROLE_CREATE);
+
+        $this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with error "Failed to perform request"'));
+
+        $webhook->refresh();
+        $this->assertEquals('Failed to perform request', $webhook->last_error);
+        $this->assertNotNull($webhook->last_errored_at);
+    }
+
+    public function test_webhook_call_data_format()
+    {
+        Http::fake([
+            '*' => Http::response('', 200),
+        ]);
+        $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+
+        $this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor);
+
+        Http::assertSent(function (Request $request) use ($editor, $page, $webhook) {
+            $reqData = $request->data();
+
+            return $request->isJson()
+                && $reqData['event'] === 'page_update'
+                && $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"')
+                && is_string($reqData['triggered_at'])
+                && $reqData['triggered_by']['name'] === $editor->name
+                && $reqData['triggered_by_profile_url'] === $editor->getProfileUrl()
+                && $reqData['webhook_id'] === $webhook->id
+                && $reqData['webhook_name'] === $webhook->name
+                && $reqData['url'] === $page->getUrl()
+                && $reqData['related_item']['name'] === $page->name;
+        });
+    }
+
+    protected function runEvent(string $event, $detail = '', ?User $user = null)
+    {
+        if (is_null($user)) {
+            $user = $this->getEditor();
+        }
+
+        $this->actingAs($user);
+
+        $activityLogger = $this->app->make(ActivityLogger::class);
+        $activityLogger->add($event, $detail);
+    }
+
+    protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
+    {
+        /** @var Webhook $webhook */
+        $webhook = Webhook::factory()->create($attrs);
+
+        foreach ($events as $event) {
+            $webhook->trackedEvents()->create(['event' => $event]);
+        }
+
+        return $webhook;
+    }
+}
diff --git a/tests/Actions/WebhookManagementTest.php b/tests/Actions/WebhookManagementTest.php
new file mode 100644 (file)
index 0000000..1fbf9b7
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+
+namespace Tests\Actions;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use Tests\TestCase;
+
+class WebhookManagementTest extends TestCase
+{
+    public function test_index_view()
+    {
+        $webhook = $this->newWebhook([
+            'name'     => 'My awesome webhook',
+            'endpoint' => 'https://example.com/donkey/webhook',
+        ], ['all']);
+
+        $resp = $this->asAdmin()->get('/settings/webhooks');
+        $resp->assertOk();
+        $resp->assertElementContains('a[href$="/settings/webhooks/create"]', 'Create New Webhook');
+        $resp->assertElementExists('a[href="' . $webhook->getUrl() . '"]', $webhook->name);
+        $resp->assertSee($webhook->endpoint);
+        $resp->assertSee('All system events');
+        $resp->assertSee('Active');
+    }
+
+    public function test_create_view()
+    {
+        $resp = $this->asAdmin()->get('/settings/webhooks/create');
+        $resp->assertOk();
+        $resp->assertSee('Create New Webhook');
+        $resp->assertElementContains('form[action$="/settings/webhooks/create"] button', 'Save Webhook');
+    }
+
+    public function test_store()
+    {
+        $resp = $this->asAdmin()->post('/settings/webhooks/create', [
+            'name'     => 'My first webhook',
+            'endpoint' => 'https://example.com/webhook',
+            'events'   => ['all'],
+            'active'   => 'true',
+            'timeout'  => 4,
+        ]);
+
+        $resp->assertRedirect('/settings/webhooks');
+        $this->assertActivityExists(ActivityType::WEBHOOK_CREATE);
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Webhook successfully created');
+
+        $this->assertDatabaseHas('webhooks', [
+            'name'     => 'My first webhook',
+            'endpoint' => 'https://example.com/webhook',
+            'active'   => true,
+            'timeout'  => 4,
+        ]);
+
+        /** @var Webhook $webhook */
+        $webhook = Webhook::query()->where('name', '=', 'My first webhook')->first();
+        $this->assertDatabaseHas('webhook_tracked_events', [
+            'webhook_id' => $webhook->id,
+            'event'      => 'all',
+        ]);
+    }
+
+    public function test_edit_view()
+    {
+        $webhook = $this->newWebhook();
+
+        $resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id);
+        $resp->assertOk();
+        $resp->assertSee('Edit Webhook');
+        $resp->assertElementContains('form[action="' . $webhook->getUrl() . '"] button', 'Save Webhook');
+        $resp->assertElementContains('a[href="' . $webhook->getUrl('/delete') . '"]', 'Delete Webhook');
+        $resp->assertElementExists('input[type="checkbox"][value="all"][name="events[]"]');
+    }
+
+    public function test_update()
+    {
+        $webhook = $this->newWebhook();
+
+        $resp = $this->asAdmin()->put('/settings/webhooks/' . $webhook->id, [
+            'name'     => 'My updated webhook',
+            'endpoint' => 'https://example.com/updated-webhook',
+            'events'   => [ActivityType::PAGE_CREATE, ActivityType::PAGE_UPDATE],
+            'active'   => 'true',
+            'timeout'  => 5,
+        ]);
+        $resp->assertRedirect('/settings/webhooks');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Webhook successfully updated');
+
+        $this->assertDatabaseHas('webhooks', [
+            'id'       => $webhook->id,
+            'name'     => 'My updated webhook',
+            'endpoint' => 'https://example.com/updated-webhook',
+            'active'   => true,
+            'timeout'  => 5,
+        ]);
+
+        $trackedEvents = $webhook->trackedEvents()->get();
+        $this->assertCount(2, $trackedEvents);
+        $this->assertEquals(['page_create', 'page_update'], $trackedEvents->pluck('event')->values()->all());
+
+        $this->assertActivityExists(ActivityType::WEBHOOK_UPDATE);
+    }
+
+    public function test_delete_view()
+    {
+        $webhook = $this->newWebhook(['name' => 'Webhook to delete']);
+
+        $resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id . '/delete');
+        $resp->assertOk();
+        $resp->assertSee('Delete Webhook');
+        $resp->assertSee('This will fully delete this webhook, with the name \'Webhook to delete\', from the system.');
+        $resp->assertElementContains('form[action$="/settings/webhooks/' . $webhook->id . '"]', 'Delete');
+    }
+
+    public function test_destroy()
+    {
+        $webhook = $this->newWebhook();
+
+        $resp = $this->asAdmin()->delete('/settings/webhooks/' . $webhook->id);
+        $resp->assertRedirect('/settings/webhooks');
+
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Webhook successfully deleted');
+
+        $this->assertDatabaseMissing('webhooks', ['id' => $webhook->id]);
+        $this->assertDatabaseMissing('webhook_tracked_events', ['webhook_id' => $webhook->id]);
+
+        $this->assertActivityExists(ActivityType::WEBHOOK_DELETE);
+    }
+
+    public function test_settings_manage_permission_required_for_webhook_routes()
+    {
+        $editor = $this->getEditor();
+        $this->actingAs($editor);
+
+        $routes = [
+            ['GET', '/settings/webhooks'],
+            ['GET', '/settings/webhooks/create'],
+            ['POST', '/settings/webhooks/create'],
+            ['GET', '/settings/webhooks/1'],
+            ['PUT', '/settings/webhooks/1'],
+            ['DELETE', '/settings/webhooks/1'],
+            ['GET', '/settings/webhooks/1/delete'],
+        ];
+
+        foreach ($routes as [$method, $endpoint]) {
+            $resp = $this->call($method, $endpoint);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->giveUserPermissions($editor, ['settings-manage']);
+
+        foreach ($routes as [$method, $endpoint]) {
+            $resp = $this->call($method, $endpoint);
+            $this->assertNotPermissionError($resp);
+        }
+    }
+
+    protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
+    {
+        /** @var Webhook $webhook */
+        $webhook = Webhook::factory()->create($attrs);
+
+        foreach ($events as $event) {
+            $webhook->trackedEvents()->create(['event' => $event]);
+        }
+
+        return $webhook;
+    }
+}
diff --git a/tests/ActivityTrackingTest.php b/tests/ActivityTrackingTest.php
deleted file mode 100644 (file)
index 9c3fe27..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php namespace Tests;
-
-
-use BookStack\Entities\Models\Book;
-
-class ActivityTrackingTest extends BrowserKitTest
-{
-
-    public function test_recently_viewed_books()
-    {
-        $books = Book::all()->take(10);
-
-        $this->asAdmin()->visit('/books')
-            ->dontSeeInElement('#recents', $books[0]->name)
-            ->dontSeeInElement('#recents', $books[1]->name)
-            ->visit($books[0]->getUrl())
-            ->visit($books[1]->getUrl())
-            ->visit('/books')
-            ->seeInElement('#recents', $books[0]->name)
-            ->seeInElement('#recents', $books[1]->name);
-    }
-
-    public function test_popular_books()
-    {
-        $books = Book::all()->take(10);
-
-        $this->asAdmin()->visit('/books')
-            ->dontSeeInElement('#popular', $books[0]->name)
-            ->dontSeeInElement('#popular', $books[1]->name)
-            ->visit($books[0]->getUrl())
-            ->visit($books[1]->getUrl())
-            ->visit($books[0]->getUrl())
-            ->visit('/books')
-            ->seeInNthElement('#popular .book', 0, $books[0]->name)
-            ->seeInNthElement('#popular .book', 1, $books[1]->name);
-    }
-}
index 3020939479b355ab0d91fab30e6dc3acedb2288e..cc6818e27aba701ce25f912e3a18ede29e08f2c8 100644 (file)
@@ -1,6 +1,9 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Auth\Permissions\RolePermission;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use Carbon\Carbon;
 use Tests\TestCase;
@@ -29,28 +32,28 @@ class ApiAuthTest extends TestCase
     {
         $resp = $this->get($this->endpoint);
         $resp->assertStatus(401);
-        $resp->assertJson($this->errorResponse("No authorization token found on the request", 401));
+        $resp->assertJson($this->errorResponse('No authorization token found on the request', 401));
     }
 
     public function test_bad_token_format_throws_error()
     {
-        $resp = $this->get($this->endpoint, ['Authorization' => "Token abc123"]);
+        $resp = $this->get($this->endpoint, ['Authorization' => 'Token abc123']);
         $resp->assertStatus(401);
-        $resp->assertJson($this->errorResponse("An authorization token was found on the request but the format appeared incorrect", 401));
+        $resp->assertJson($this->errorResponse('An authorization token was found on the request but the format appeared incorrect', 401));
     }
 
     public function test_token_with_non_existing_id_throws_error()
     {
-        $resp = $this->get($this->endpoint, ['Authorization' => "Token abc:123"]);
+        $resp = $this->get($this->endpoint, ['Authorization' => 'Token abc:123']);
         $resp->assertStatus(401);
-        $resp->assertJson($this->errorResponse("No matching API token was found for the provided authorization token", 401));
+        $resp->assertJson($this->errorResponse('No matching API token was found for the provided authorization token', 401));
     }
 
     public function test_token_with_bad_secret_value_throws_error()
     {
         $resp = $this->get($this->endpoint, ['Authorization' => "Token {$this->apiTokenId}:123"]);
         $resp->assertStatus(401);
-        $resp->assertJson($this->errorResponse("The secret provided for the given used API token is incorrect", 401));
+        $resp->assertJson($this->errorResponse('The secret provided for the given used API token is incorrect', 401));
     }
 
     public function test_api_access_permission_required_to_access_api()
@@ -65,7 +68,7 @@ class ApiAuthTest extends TestCase
 
         $resp = $this->get($this->endpoint, $this->apiAuthHeader());
         $resp->assertStatus(403);
-        $resp->assertJson($this->errorResponse("The owner of the used API token does not have permission to make API calls", 403));
+        $resp->assertJson($this->errorResponse('The owner of the used API token does not have permission to make API calls', 403));
     }
 
     public function test_api_access_permission_required_to_access_api_with_session_auth()
@@ -86,7 +89,27 @@ class ApiAuthTest extends TestCase
         $this->actingAs($editor, 'standard');
         $resp = $this->get($this->endpoint);
         $resp->assertStatus(403);
-        $resp->assertJson($this->errorResponse("The owner of the used API token does not have permission to make API calls", 403));
+        $resp->assertJson($this->errorResponse('The owner of the used API token does not have permission to make API calls', 403));
+    }
+
+    public function test_access_prevented_for_guest_users_with_api_permission_while_public_access_disabled()
+    {
+        $this->disableCookieEncryption();
+        $publicRole = Role::getSystemRole('public');
+        $accessApiPermission = RolePermission::getByName('access-api');
+        $publicRole->attachPermission($accessApiPermission);
+
+        $this->withCookie('bookstack_session', 'abc123');
+
+        // Test API access when not public
+        setting()->put('app-public', false);
+        $resp = $this->get($this->endpoint);
+        $resp->assertStatus(403);
+
+        // Test API access when public
+        setting()->put('app-public', true);
+        $resp = $this->get($this->endpoint);
+        $resp->assertStatus(200);
     }
 
     public function test_token_expiry_checked()
@@ -102,7 +125,7 @@ class ApiAuthTest extends TestCase
         $token->save();
 
         $resp = $this->get($this->endpoint, $this->apiAuthHeader());
-        $resp->assertJson($this->errorResponse("The authorization token used has expired", 403));
+        $resp->assertJson($this->errorResponse('The authorization token used has expired', 403));
     }
 
     public function test_email_confirmation_checked_using_api_auth()
@@ -116,7 +139,7 @@ class ApiAuthTest extends TestCase
 
         $resp = $this->get($this->endpoint, $this->apiAuthHeader());
         $resp->assertStatus(401);
-        $resp->assertJson($this->errorResponse("The email address for the account in use needs to be confirmed", 401));
+        $resp->assertJson($this->errorResponse('The email address for the account in use needs to be confirmed', 401));
     }
 
     public function test_rate_limit_headers_active_on_requests()
@@ -141,7 +164,7 @@ class ApiAuthTest extends TestCase
         $resp->assertJson([
             'error' => [
                 'code' => 429,
-            ]
+            ],
         ]);
     }
-}
\ No newline at end of file
+}
index def62c94dd40465899f3e794e2c6b7897a963ff4..af808a76ca3f545535aa2ea9b3f494f0966fa48e 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use Tests\TestCase;
 
@@ -51,5 +53,4 @@ class ApiConfigTest extends TestCase
         $resp = $this->actingAsApiEditor()->get($this->endpoint);
         $resp->assertHeader('x-ratelimit-limit', 10);
     }
-
-}
\ No newline at end of file
+}
index 1687c64a17e10a7a5110166d251be7c2721afcf1..56b09cfb85d707142c875af1daa77a174aaa6979 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
-use BookStack\Auth\User;
 use Tests\TestCase;
 
 class ApiDocsTest extends TestCase
@@ -9,14 +10,10 @@ class ApiDocsTest extends TestCase
 
     protected $endpoint = '/api/docs';
 
-    public function test_docs_page_not_visible_to_normal_viewers()
+    public function test_api_endpoint_redirects_to_docs()
     {
-        $viewer = $this->getViewer();
-        $resp = $this->actingAs($viewer)->get($this->endpoint);
-        $resp->assertStatus(403);
-
-        $resp = $this->actingAsApiEditor()->get($this->endpoint);
-        $resp->assertStatus(200);
+        $resp = $this->actingAsApiEditor()->get('/api');
+        $resp->assertRedirect('api/docs');
     }
 
     public function test_docs_page_returns_view_with_docs_content()
@@ -34,25 +31,10 @@ class ApiDocsTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertHeader('Content-Type', 'application/json');
         $resp->assertJson([
-            'docs' => [ [
+            'docs' => [[
                 'name' => 'docs-display',
-                'uri' => 'api/docs'
-            ] ]
+                'uri'  => 'api/docs',
+            ]],
         ]);
     }
-
-    public function test_docs_page_visible_by_public_user_if_given_permission()
-    {
-        $this->setSettings(['app-public' => true]);
-        $guest = User::getDefault();
-
-        $this->startSession();
-        $resp = $this->get('/api/docs');
-        $resp->assertStatus(403);
-
-        $this->giveUserPermissions($guest, ['access-api']);
-
-        $resp = $this->get('/api/docs');
-        $resp->assertStatus(200);
-    }
-}
\ No newline at end of file
+}
index c3d9bc10888d51e91bc1a584cadabf6ff27b3642..f90ec5a3dd2a30fc806ca620e36919f6be11390e 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Entities\Models\Book;
 use Tests\TestCase;
@@ -27,7 +29,7 @@ class ApiListingTest extends TestCase
         $books = Book::visible()->orderBy('id')->take(3)->get();
 
         $resp = $this->get($this->endpoint . '?count=1');
-        $resp->assertJsonMissing(['name' => $books[1]->name ]);
+        $resp->assertJsonMissing(['name' => $books[1]->name]);
 
         $resp = $this->get($this->endpoint . '?count=1&offset=1000');
         $resp->assertJsonCount(0, 'data');
@@ -38,19 +40,19 @@ class ApiListingTest extends TestCase
         $this->actingAsApiEditor();
 
         $sortChecks = [
-            '-id' => Book::visible()->orderBy('id', 'desc')->first(),
+            '-id'   => Book::visible()->orderBy('id', 'desc')->first(),
             '+name' => Book::visible()->orderBy('name', 'asc')->first(),
-            'name' => Book::visible()->orderBy('name', 'asc')->first(),
-            '-name' => Book::visible()->orderBy('name', 'desc')->first()
+            'name'  => Book::visible()->orderBy('name', 'asc')->first(),
+            '-name' => Book::visible()->orderBy('name', 'desc')->first(),
         ];
 
         foreach ($sortChecks as $sortOption => $result) {
             $resp = $this->get($this->endpoint . '?count=1&sort=' . $sortOption);
             $resp->assertJson(['data' => [
                 [
-                    'id' => $result->id,
+                    'id'   => $result->id,
                     'name' => $result->name,
-                ]
+                ],
             ]]);
         }
     }
@@ -64,11 +66,11 @@ class ApiListingTest extends TestCase
 
         $filterChecks = [
             // Test different types of filter
-            "filter[id]={$book->id}" => 1,
-            "filter[id:ne]={$book->id}" => Book::visible()->where('id', '!=', $book->id)->count(),
-            "filter[id:gt]={$book->id}" => Book::visible()->where('id', '>', $book->id)->count(),
-            "filter[id:gte]={$book->id}" => Book::visible()->where('id', '>=', $book->id)->count(),
-            "filter[id:lt]={$book->id}" => Book::visible()->where('id', '<', $book->id)->count(),
+            "filter[id]={$book->id}"                  => 1,
+            "filter[id:ne]={$book->id}"               => Book::visible()->where('id', '!=', $book->id)->count(),
+            "filter[id:gt]={$book->id}"               => Book::visible()->where('id', '>', $book->id)->count(),
+            "filter[id:gte]={$book->id}"              => Book::visible()->where('id', '>=', $book->id)->count(),
+            "filter[id:lt]={$book->id}"               => Book::visible()->where('id', '<', $book->id)->count(),
             "filter[name:like]={$encodedNameSubstr}%" => Book::visible()->where('name', 'like', $nameSubstr . '%')->count(),
 
             // Test mulitple filters 'and' together
@@ -86,7 +88,7 @@ class ApiListingTest extends TestCase
         $this->actingAsApiEditor();
         $bookCount = Book::query()->count();
         $resp = $this->get($this->endpoint . '?count=1');
-        $resp->assertJson(['total' => $bookCount ]);
+        $resp->assertJson(['total' => $bookCount]);
     }
 
     public function test_total_on_results_shows_correctly_when_offset_provided()
@@ -94,7 +96,6 @@ class ApiListingTest extends TestCase
         $this->actingAsApiEditor();
         $bookCount = Book::query()->count();
         $resp = $this->get($this->endpoint . '?count=1&offset=1');
-        $resp->assertJson(['total' => $bookCount ]);
+        $resp->assertJson(['total' => $bookCount]);
     }
-
-}
\ No newline at end of file
+}
diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php
new file mode 100644 (file)
index 0000000..bfa4734
--- /dev/null
@@ -0,0 +1,353 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Uploads\Attachment;
+use Illuminate\Http\UploadedFile;
+use Tests\TestCase;
+
+class AttachmentsApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected $baseEndpoint = '/api/attachments';
+
+    public function test_index_endpoint_returns_expected_book()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'     => 'My test attachment',
+            'external' => true,
+        ]);
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJson(['data' => [
+            [
+                'id'          => $attachment->id,
+                'name'        => 'My test attachment',
+                'uploaded_to' => $page->id,
+                'external'    => true,
+            ],
+        ]]);
+    }
+
+    public function test_attachments_listing_based_upon_page_visibility()
+    {
+        $this->actingAsApiEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'     => 'My test attachment',
+            'external' => true,
+        ]);
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJson(['data' => [
+            [
+                'id' => $attachment->id,
+            ],
+        ]]);
+
+        $page->restricted = true;
+        $page->save();
+        $this->regenEntityPermissions($page);
+
+        $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+        $resp->assertJsonMissing(['data' => [
+            [
+                'id' => $attachment->id,
+            ],
+        ]]);
+    }
+
+    public function test_create_endpoint_for_link_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $details = [
+            'name'        => 'My attachment',
+            'uploaded_to' => $page->id,
+            'link'        => 'https://cats.example.com',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(200);
+        /** @var Attachment $newItem */
+        $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+        $resp->assertJson(['id' => $newItem->id, 'external' => true, 'name' => $details['name'], 'uploaded_to' => $page->id]);
+    }
+
+    public function test_create_endpoint_for_upload_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $file = $this->getTestFile('textfile.txt');
+
+        $details = [
+            'name'        => 'My attachment',
+            'uploaded_to' => $page->id,
+        ];
+
+        $resp = $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]);
+        $resp->assertStatus(200);
+        /** @var Attachment $newItem */
+        $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+        $resp->assertJson(['id' => $newItem->id, 'external' => false, 'extension' => 'txt', 'name' => $details['name'], 'uploaded_to' => $page->id]);
+        $this->assertTrue(file_exists(storage_path($newItem->path)));
+        unlink(storage_path($newItem->path));
+    }
+
+    public function test_name_needed_to_create()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $details = [
+            'uploaded_to' => $page->id,
+            'link'        => 'https://example.com',
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(422);
+        $resp->assertJson([
+            'error' => [
+                'message'    => 'The given data was invalid.',
+                'validation' => [
+                    'name' => ['The name field is required.'],
+                ],
+                'code' => 422,
+            ],
+        ]);
+    }
+
+    public function test_link_or_file_needed_to_create()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $details = [
+            'name'        => 'my attachment',
+            'uploaded_to' => $page->id,
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertStatus(422);
+        $resp->assertJson([
+            'error' => [
+                'message'    => 'The given data was invalid.',
+                'validation' => [
+                    'file' => ['The file field is required when link is not present.'],
+                    'link' => ['The link field is required when file is not present.'],
+                ],
+                'code' => 422,
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_for_link_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'  => 'my attachment',
+            'path'  => 'https://example.com',
+            'order' => 1,
+        ]);
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'id'          => $attachment->id,
+            'content'     => 'https://example.com',
+            'external'    => true,
+            'uploaded_to' => $page->id,
+            'order'       => 1,
+            'created_by'  => [
+                'name' => $attachment->createdBy->name,
+            ],
+            'updated_by' => [
+                'name' => $attachment->createdBy->name,
+            ],
+            'links' => [
+                'html'     => "<a target=\"_blank\" href=\"http://localhost/attachments/{$attachment->id}\">my attachment</a>",
+                'markdown' => "[my attachment](http://localhost/attachments/{$attachment->id})",
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_for_file_attachment()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $file = $this->getTestFile('textfile.txt');
+
+        $details = [
+            'name'        => 'My file attachment',
+            'uploaded_to' => $page->id,
+        ];
+        $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail();
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'id'          => $attachment->id,
+            'content'     => base64_encode(file_get_contents(storage_path($attachment->path))),
+            'external'    => false,
+            'uploaded_to' => $page->id,
+            'order'       => 1,
+            'created_by'  => [
+                'name' => $attachment->createdBy->name,
+            ],
+            'updated_by' => [
+                'name' => $attachment->updatedBy->name,
+            ],
+            'links' => [
+                'html'     => "<a target=\"_blank\" href=\"http://localhost/attachments/{$attachment->id}\">My file attachment</a>",
+                'markdown' => "[My file attachment](http://localhost/attachments/{$attachment->id})",
+            ],
+        ]);
+
+        unlink(storage_path($attachment->path));
+    }
+
+    public function test_attachment_not_visible_on_other_users_draft()
+    {
+        $this->actingAsApiAdmin();
+        $editor = $this->getEditor();
+
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->draft = true;
+        $page->owned_by = $editor;
+        $page->save();
+        $this->regenEntityPermissions($page);
+
+        $attachment = $this->createAttachmentForPage($page, [
+            'name'  => 'my attachment',
+            'path'  => 'https://example.com',
+            'order' => 1,
+        ]);
+
+        $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(404);
+    }
+
+    public function test_update_endpoint()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page);
+
+        $details = [
+            'name' => 'My updated API attachment',
+        ];
+
+        $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details);
+        $attachment->refresh();
+
+        $resp->assertStatus(200);
+        $resp->assertJson(['id' => $attachment->id, 'name' => 'My updated API attachment']);
+    }
+
+    public function test_update_link_attachment_to_file()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page);
+        $file = $this->getTestFile('textfile.txt');
+
+        $resp = $this->call('PUT', "{$this->baseEndpoint}/{$attachment->id}", ['name' => 'My updated file'], [], ['file' => $file]);
+        $resp->assertStatus(200);
+
+        $attachment->refresh();
+        $this->assertFalse($attachment->external);
+        $this->assertEquals('txt', $attachment->extension);
+        $this->assertStringStartsWith('uploads/files/', $attachment->path);
+        $this->assertFileExists(storage_path($attachment->path));
+
+        unlink(storage_path($attachment->path));
+    }
+
+    public function test_update_file_attachment_to_link()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $file = $this->getTestFile('textfile.txt');
+        $this->call('POST', $this->baseEndpoint, ['name' => 'My file attachment', 'uploaded_to' => $page->id], [], ['file' => $file]);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->where('name', '=', 'My file attachment')->firstOrFail();
+
+        $filePath = storage_path($attachment->path);
+        $this->assertFileExists($filePath);
+
+        $details = [
+            'name' => 'My updated API attachment',
+            'link' => 'https://cats.example.com',
+        ];
+
+        $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details);
+        $resp->assertStatus(200);
+        $attachment->refresh();
+
+        $this->assertFileDoesNotExist($filePath);
+        $this->assertTrue($attachment->external);
+        $this->assertEquals('https://cats.example.com', $attachment->path);
+        $this->assertEquals('', $attachment->extension);
+    }
+
+    public function test_delete_endpoint()
+    {
+        $this->actingAsApiAdmin();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $attachment = $this->createAttachmentForPage($page);
+
+        $resp = $this->deleteJson("{$this->baseEndpoint}/{$attachment->id}");
+
+        $resp->assertStatus(204);
+        $this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
+    }
+
+    protected function createAttachmentForPage(Page $page, $attributes = []): Attachment
+    {
+        $admin = $this->getAdmin();
+        /** @var Attachment $attachment */
+        $attachment = $page->attachments()->forceCreate(array_merge([
+            'uploaded_to' => $page->id,
+            'name'        => 'test attachment',
+            'external'    => true,
+            'order'       => 1,
+            'created_by'  => $admin->id,
+            'updated_by'  => $admin->id,
+            'path'        => 'https://attachment.example.com',
+        ], $attributes));
+
+        return $attachment;
+    }
+
+    /**
+     * Get a test file that can be uploaded.
+     */
+    protected function getTestFile(string $fileName): UploadedFile
+    {
+        return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', null, true);
+    }
+}
index a36acdd0253bf961b0c3d44844cae14cd557f4ff..91e2db9e52de5c4cf7bddd0df659e6cdc79c42c8 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Entities\Models\Book;
 use Tests\TestCase;
@@ -17,10 +19,10 @@ class BooksApiTest extends TestCase
         $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
         $resp->assertJson(['data' => [
             [
-                'id' => $firstBook->id,
+                'id'   => $firstBook->id,
                 'name' => $firstBook->name,
                 'slug' => $firstBook->slug,
-            ]
+            ],
         ]]);
     }
 
@@ -28,7 +30,7 @@ class BooksApiTest extends TestCase
     {
         $this->actingAsApiEditor();
         $details = [
-            'name' => 'My API book',
+            'name'        => 'My API book',
             'description' => 'A book created via the API',
         ];
 
@@ -49,12 +51,12 @@ class BooksApiTest extends TestCase
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(422);
         $resp->assertJson([
-            "error" => [
-                "message" => "The given data was invalid.",
-                "validation" => [
-                    "name" => ["The name field is required."]
+            'error' => [
+                'message'    => 'The given data was invalid.',
+                'validation' => [
+                    'name' => ['The name field is required.'],
                 ],
-                "code" => 422,
+                'code' => 422,
             ],
         ]);
     }
@@ -68,8 +70,8 @@ class BooksApiTest extends TestCase
 
         $resp->assertStatus(200);
         $resp->assertJson([
-            'id' => $book->id,
-            'slug' => $book->slug,
+            'id'         => $book->id,
+            'slug'       => $book->slug,
             'created_by' => [
                 'name' => $book->createdBy->name,
             ],
@@ -77,7 +79,7 @@ class BooksApiTest extends TestCase
                 'name' => $book->createdBy->name,
             ],
             'owned_by' => [
-                'name' => $book->ownedBy->name
+                'name' => $book->ownedBy->name,
             ],
         ]);
     }
@@ -87,7 +89,7 @@ class BooksApiTest extends TestCase
         $this->actingAsApiEditor();
         $book = Book::visible()->first();
         $details = [
-            'name' => 'My updated API book',
+            'name'        => 'My updated API book',
             'description' => 'A book created via the API',
         ];
 
@@ -140,4 +142,30 @@ class BooksApiTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"');
     }
-}
\ No newline at end of file
+
+    public function test_export_markdown_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::visible()->has('pages')->has('chapters')->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/markdown");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"');
+        $resp->assertSee('# ' . $book->name);
+        $resp->assertSee('# ' . $book->pages()->first()->name);
+        $resp->assertSee('# ' . $book->chapters()->first()->name);
+    }
+
+    public function test_cant_export_when_not_have_permission()
+    {
+        $types = ['html', 'plaintext', 'pdf', 'markdown'];
+        $this->actingAsApiEditor();
+        $this->removePermissionFromUser($this->getEditor(), 'content-export');
+
+        $book = Book::visible()->first();
+        foreach ($types as $type) {
+            $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/{$type}");
+            $this->assertPermissionError($resp);
+        }
+    }
+}
index c7368eaee1835209d64df5ce90642f864bf3131b..c9ed1a2892e19a715dd344e4a4ac8e6b5302b41a 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
@@ -18,12 +20,12 @@ class ChaptersApiTest extends TestCase
         $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
         $resp->assertJson(['data' => [
             [
-                'id' => $firstChapter->id,
-                'name' => $firstChapter->name,
-                'slug' => $firstChapter->slug,
-                'book_id' => $firstChapter->book->id,
+                'id'       => $firstChapter->id,
+                'name'     => $firstChapter->name,
+                'slug'     => $firstChapter->slug,
+                'book_id'  => $firstChapter->book->id,
                 'priority' => $firstChapter->priority,
-            ]
+            ],
         ]]);
     }
 
@@ -32,15 +34,15 @@ class ChaptersApiTest extends TestCase
         $this->actingAsApiEditor();
         $book = Book::query()->first();
         $details = [
-            'name' => 'My API chapter',
+            'name'        => 'My API chapter',
             'description' => 'A chapter created via the API',
-            'book_id' => $book->id,
-            'tags' => [
+            'book_id'     => $book->id,
+            'tags'        => [
                 [
-                    'name' => 'tagname',
+                    'name'  => 'tagname',
                     'value' => 'tagvalue',
-                ]
-            ]
+                ],
+            ],
         ];
 
         $resp = $this->postJson($this->baseEndpoint, $details);
@@ -48,10 +50,10 @@ class ChaptersApiTest extends TestCase
         $newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
         $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
         $this->assertDatabaseHas('tags', [
-            'entity_id' => $newItem->id,
+            'entity_id'   => $newItem->id,
             'entity_type' => $newItem->getMorphClass(),
-            'name' => 'tagname',
-            'value' => 'tagvalue',
+            'name'        => 'tagname',
+            'value'       => 'tagvalue',
         ]);
         $resp->assertJsonMissing(['pages' => []]);
         $this->assertActivityExists('chapter_create', $newItem);
@@ -62,14 +64,14 @@ class ChaptersApiTest extends TestCase
         $this->actingAsApiEditor();
         $book = Book::query()->first();
         $details = [
-            'book_id' => $book->id,
+            'book_id'     => $book->id,
             'description' => 'A chapter created via the API',
         ];
 
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(422);
         $resp->assertJson($this->validationResponse([
-            "name" => ["The name field is required."]
+            'name' => ['The name field is required.'],
         ]));
     }
 
@@ -77,14 +79,14 @@ class ChaptersApiTest extends TestCase
     {
         $this->actingAsApiEditor();
         $details = [
-            'name' => 'My api chapter',
+            'name'        => 'My api chapter',
             'description' => 'A chapter created via the API',
         ];
 
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(422);
         $resp->assertJson($this->validationResponse([
-            "book_id" => ["The book id field is required."]
+            'book_id' => ['The book id field is required.'],
         ]));
     }
 
@@ -97,24 +99,24 @@ class ChaptersApiTest extends TestCase
         $resp = $this->getJson($this->baseEndpoint . "/{$chapter->id}");
         $resp->assertStatus(200);
         $resp->assertJson([
-            'id' => $chapter->id,
-            'slug' => $chapter->slug,
+            'id'         => $chapter->id,
+            'slug'       => $chapter->slug,
             'created_by' => [
                 'name' => $chapter->createdBy->name,
             ],
-            'book_id' => $chapter->book_id,
+            'book_id'    => $chapter->book_id,
             'updated_by' => [
                 'name' => $chapter->createdBy->name,
             ],
             'owned_by' => [
-                'name' => $chapter->ownedBy->name
+                'name' => $chapter->ownedBy->name,
             ],
             'pages' => [
                 [
-                    'id' => $page->id,
+                    'id'   => $page->id,
                     'slug' => $page->slug,
                     'name' => $page->name,
-                ]
+                ],
             ],
         ]);
         $resp->assertJsonCount($chapter->pages()->count(), 'pages');
@@ -125,13 +127,13 @@ class ChaptersApiTest extends TestCase
         $this->actingAsApiEditor();
         $chapter = Chapter::visible()->first();
         $details = [
-            'name' => 'My updated API chapter',
+            'name'        => 'My updated API chapter',
             'description' => 'A chapter created via the API',
-            'tags' => [
+            'tags'        => [
                 [
-                    'name' => 'freshtag',
+                    'name'  => 'freshtag',
                     'value' => 'freshtagval',
-                ]
+                ],
             ],
         ];
 
@@ -140,7 +142,7 @@ class ChaptersApiTest extends TestCase
 
         $resp->assertStatus(200);
         $resp->assertJson(array_merge($details, [
-            'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id
+            'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id,
         ]));
         $this->assertActivityExists('chapter_update', $chapter);
     }
@@ -186,4 +188,29 @@ class ChaptersApiTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"');
     }
-}
\ No newline at end of file
+
+    public function test_export_markdown_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $chapter = Chapter::visible()->has('pages')->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/markdown");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"');
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $chapter->pages()->first()->name);
+    }
+
+    public function test_cant_export_when_not_have_permission()
+    {
+        $types = ['html', 'plaintext', 'pdf', 'markdown'];
+        $this->actingAsApiEditor();
+        $this->removePermissionFromUser($this->getEditor(), 'content-export');
+
+        $chapter = Chapter::visible()->has('pages')->first();
+        foreach ($types as $type) {
+            $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/{$type}");
+            $this->assertPermissionError($resp);
+        }
+    }
+}
index e08e9b1b742b1424f4b6dba465ea994395d80908..4eb109d9dec3acf35653740aa51f5af714d122c4 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
@@ -19,12 +21,12 @@ class PagesApiTest extends TestCase
         $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
         $resp->assertJson(['data' => [
             [
-                'id' => $firstPage->id,
-                'name' => $firstPage->name,
-                'slug' => $firstPage->slug,
-                'book_id' => $firstPage->book->id,
+                'id'       => $firstPage->id,
+                'name'     => $firstPage->name,
+                'slug'     => $firstPage->slug,
+                'book_id'  => $firstPage->book->id,
                 'priority' => $firstPage->priority,
-            ]
+            ],
         ]]);
     }
 
@@ -33,15 +35,15 @@ class PagesApiTest extends TestCase
         $this->actingAsApiEditor();
         $book = Book::query()->first();
         $details = [
-            'name' => 'My API page',
+            'name'    => 'My API page',
             'book_id' => $book->id,
-            'html' => '<p>My new page content</p>',
-            'tags' => [
+            'html'    => '<p>My new page content</p>',
+            'tags'    => [
                 [
-                    'name' => 'tagname',
+                    'name'  => 'tagname',
                     'value' => 'tagvalue',
-                ]
-            ]
+                ],
+            ],
         ];
 
         $resp = $this->postJson($this->baseEndpoint, $details);
@@ -50,10 +52,10 @@ class PagesApiTest extends TestCase
         $newItem = Page::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
         $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
         $this->assertDatabaseHas('tags', [
-            'entity_id' => $newItem->id,
+            'entity_id'   => $newItem->id,
             'entity_type' => $newItem->getMorphClass(),
-            'name' => 'tagname',
-            'value' => 'tagvalue',
+            'name'        => 'tagname',
+            'value'       => 'tagvalue',
         ]);
         $resp->assertSeeText('My new page content');
         $resp->assertJsonMissing(['book' => []]);
@@ -66,13 +68,13 @@ class PagesApiTest extends TestCase
         $book = Book::query()->first();
         $details = [
             'book_id' => $book->id,
-            'html' => '<p>A page created via the API</p>',
+            'html'    => '<p>A page created via the API</p>',
         ];
 
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(422);
         $resp->assertJson($this->validationResponse([
-            "name" => ["The name field is required."]
+            'name' => ['The name field is required.'],
         ]));
     }
 
@@ -87,8 +89,8 @@ class PagesApiTest extends TestCase
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(422);
         $resp->assertJson($this->validationResponse([
-            "book_id" => ["The book id field is required when chapter id is not present."],
-            "chapter_id" => ["The chapter id field is required when book id is not present."]
+            'book_id'    => ['The book id field is required when chapter id is not present.'],
+            'chapter_id' => ['The chapter id field is required when book id is not present.'],
         ]));
 
         $chapter = Chapter::visible()->first();
@@ -105,8 +107,8 @@ class PagesApiTest extends TestCase
         $this->actingAsApiEditor();
         $book = Book::visible()->first();
         $details = [
-            'book_id' => $book->id,
-            'name' => 'My api page',
+            'book_id'  => $book->id,
+            'name'     => 'My api page',
             'markdown' => "# A new API page \n[link](https://example.com)",
         ];
 
@@ -127,17 +129,17 @@ class PagesApiTest extends TestCase
         $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
         $resp->assertStatus(200);
         $resp->assertJson([
-            'id' => $page->id,
-            'slug' => $page->slug,
+            'id'         => $page->id,
+            'slug'       => $page->slug,
             'created_by' => [
                 'name' => $page->createdBy->name,
             ],
-            'book_id' => $page->book_id,
+            'book_id'    => $page->book_id,
             'updated_by' => [
                 'name' => $page->createdBy->name,
             ],
             'owned_by' => [
-                'name' => $page->ownedBy->name
+                'name' => $page->ownedBy->name,
             ],
         ]);
     }
@@ -165,9 +167,9 @@ class PagesApiTest extends TestCase
             'html' => '<p>A page created via the API</p>',
             'tags' => [
                 [
-                    'name' => 'freshtag',
+                    'name'  => 'freshtag',
                     'value' => 'freshtagval',
-                ]
+                ],
             ],
         ];
 
@@ -177,7 +179,7 @@ class PagesApiTest extends TestCase
         $resp->assertStatus(200);
         unset($details['html']);
         $resp->assertJson(array_merge($details, [
-            'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id
+            'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id,
         ]));
         $this->assertActivityExists('page_update', $page);
     }
@@ -188,16 +190,16 @@ class PagesApiTest extends TestCase
         $page = Page::visible()->first();
         $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
         $details = [
-            'name' => 'My updated API page',
+            'name'       => 'My updated API page',
             'chapter_id' => $chapter->id,
-            'html' => '<p>A page created via the API</p>',
+            'html'       => '<p>A page created via the API</p>',
         ];
 
         $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
         $resp->assertStatus(200);
         $resp->assertJson([
             'chapter_id' => $chapter->id,
-            'book_id' => $chapter->book_id,
+            'book_id'    => $chapter->book_id,
         ]);
     }
 
@@ -208,15 +210,36 @@ class PagesApiTest extends TestCase
         $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
         $this->setEntityRestrictions($chapter, ['view'], [$this->getEditor()->roles()->first()]);
         $details = [
-            'name' => 'My updated API page',
+            'name'       => 'My updated API page',
             'chapter_id' => $chapter->id,
-            'html' => '<p>A page created via the API</p>',
+            'html'       => '<p>A page created via the API</p>',
         ];
 
         $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
         $resp->assertStatus(403);
     }
 
+    public function test_update_endpoint_does_not_wipe_content_if_no_html_or_md_provided()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $originalContent = $page->html;
+        $details = [
+            'name' => 'My updated API page',
+            'tags' => [
+                [
+                    'name'  => 'freshtag',
+                    'value' => 'freshtagval',
+                ],
+            ],
+        ];
+
+        $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+        $page->refresh();
+
+        $this->assertEquals($originalContent, $page->html);
+    }
+
     public function test_delete_endpoint()
     {
         $this->actingAsApiEditor();
@@ -258,4 +281,28 @@ class PagesApiTest extends TestCase
         $resp->assertStatus(200);
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
     }
-}
\ No newline at end of file
+
+    public function test_export_markdown_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/markdown");
+        $resp->assertStatus(200);
+        $resp->assertSee('# ' . $page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
+    }
+
+    public function test_cant_export_when_not_have_permission()
+    {
+        $types = ['html', 'plaintext', 'pdf', 'markdown'];
+        $this->actingAsApiEditor();
+        $this->removePermissionFromUser($this->getEditor(), 'content-export');
+
+        $page = Page::visible()->first();
+        foreach ($types as $type) {
+            $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/{$type}");
+            $this->assertPermissionError($resp);
+        }
+    }
+}
diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php
new file mode 100644 (file)
index 0000000..1f38c7f
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class SearchApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected $baseEndpoint = '/api/search';
+
+    public function test_all_endpoint_returns_search_filtered_results_with_query()
+    {
+        $this->actingAsApiEditor();
+        $uniqueTerm = 'MySuperUniqueTermForSearching';
+
+        /** @var Entity $entityClass */
+        foreach ([Page::class, Chapter::class, Book::class, Bookshelf::class] as $entityClass) {
+            /** @var Entity $first */
+            $first = $entityClass::query()->first();
+            $first->update(['name' => $uniqueTerm]);
+            $first->indexForSearch();
+        }
+
+        $resp = $this->getJson($this->baseEndpoint . '?query=' . $uniqueTerm . '&count=5&page=1');
+        $resp->assertJsonCount(4, 'data');
+        $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'book']);
+        $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'chapter']);
+        $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'page']);
+        $resp->assertJsonFragment(['name' => $uniqueTerm, 'type' => 'bookshelf']);
+    }
+
+    public function test_all_endpoint_returns_entity_url()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->update(['name' => 'name with superuniquevalue within']);
+        $page->indexForSearch();
+
+        $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
+        $resp->assertJsonFragment([
+            'type' => 'page',
+            'url'  => $page->getUrl(),
+        ]);
+    }
+
+    public function test_all_endpoint_returns_items_with_preview_html()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $book->update(['name' => 'name with superuniquevalue within', 'description' => 'Description with superuniquevalue within']);
+        $book->indexForSearch();
+
+        $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue');
+        $resp->assertJsonFragment([
+            'type'         => 'book',
+            'url'          => $book->getUrl(),
+            'preview_html' => [
+                'name'    => 'name with <strong>superuniquevalue</strong> within',
+                'content' => 'Description with <strong>superuniquevalue</strong> within',
+            ],
+        ]);
+    }
+
+    public function test_all_endpoint_requires_query_parameter()
+    {
+        $resp = $this->actingAsApiEditor()->get($this->baseEndpoint);
+        $resp->assertStatus(422);
+
+        $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue');
+        $resp->assertOk();
+    }
+}
index 32715dd0a1630165af36f5f86254931bd8874ff8..8868c686e5086231fb26f5c7d2a7ce75e202c572 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -18,10 +20,10 @@ class ShelvesApiTest extends TestCase
         $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
         $resp->assertJson(['data' => [
             [
-                'id' => $firstBookshelf->id,
+                'id'   => $firstBookshelf->id,
                 'name' => $firstBookshelf->name,
                 'slug' => $firstBookshelf->slug,
-            ]
+            ],
         ]]);
     }
 
@@ -31,7 +33,7 @@ class ShelvesApiTest extends TestCase
         $books = Book::query()->take(2)->get();
 
         $details = [
-            'name' => 'My API shelf',
+            'name'        => 'My API shelf',
             'description' => 'A shelf created via the API',
         ];
 
@@ -43,8 +45,8 @@ class ShelvesApiTest extends TestCase
         foreach ($books as $index => $book) {
             $this->assertDatabaseHas('bookshelves_books', [
                 'bookshelf_id' => $newItem->id,
-                'book_id' => $book->id,
-                'order' => $index,
+                'book_id'      => $book->id,
+                'order'        => $index,
             ]);
         }
     }
@@ -59,12 +61,12 @@ class ShelvesApiTest extends TestCase
         $resp = $this->postJson($this->baseEndpoint, $details);
         $resp->assertStatus(422);
         $resp->assertJson([
-            "error" => [
-                "message" => "The given data was invalid.",
-                "validation" => [
-                    "name" => ["The name field is required."]
+            'error' => [
+                'message'    => 'The given data was invalid.',
+                'validation' => [
+                    'name' => ['The name field is required.'],
                 ],
-                "code" => 422,
+                'code' => 422,
             ],
         ]);
     }
@@ -78,8 +80,8 @@ class ShelvesApiTest extends TestCase
 
         $resp->assertStatus(200);
         $resp->assertJson([
-            'id' => $shelf->id,
-            'slug' => $shelf->slug,
+            'id'         => $shelf->id,
+            'slug'       => $shelf->slug,
             'created_by' => [
                 'name' => $shelf->createdBy->name,
             ],
@@ -87,7 +89,7 @@ class ShelvesApiTest extends TestCase
                 'name' => $shelf->createdBy->name,
             ],
             'owned_by' => [
-                'name' => $shelf->ownedBy->name
+                'name' => $shelf->ownedBy->name,
             ],
         ]);
     }
@@ -97,7 +99,7 @@ class ShelvesApiTest extends TestCase
         $this->actingAsApiEditor();
         $shelf = Bookshelf::visible()->first();
         $details = [
-            'name' => 'My updated API shelf',
+            'name'        => 'My updated API shelf',
             'description' => 'A shelf created via the API',
         ];
 
@@ -136,4 +138,4 @@ class ShelvesApiTest extends TestCase
         $resp->assertStatus(204);
         $this->assertActivityExists('bookshelf_delete');
     }
-}
\ No newline at end of file
+}
index 1ad4d14b64e4c7137134552848b1f7c800dbed6f..97ca82ea71c914bf5597408f8dd5fc5aec2087b2 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 trait TestsApi
 {
-
     protected $apiTokenId = 'apitoken';
     protected $apiTokenSecret = 'password';
 
@@ -12,6 +13,17 @@ trait TestsApi
     protected function actingAsApiEditor()
     {
         $this->actingAs($this->getEditor(), 'api');
+
+        return $this;
+    }
+
+    /**
+     * Set the API admin role as the current user via the API driver.
+     */
+    protected function actingAsApiAdmin()
+    {
+        $this->actingAs($this->getAdmin(), 'api');
+
         return $this;
     }
 
@@ -20,7 +32,7 @@ trait TestsApi
      */
     protected function errorResponse(string $message, int $code): array
     {
-        return ["error" => ["code" => $code, "message" => $message]];
+        return ['error' => ['code' => $code, 'message' => $message]];
     }
 
     /**
@@ -29,18 +41,19 @@ trait TestsApi
      */
     protected function validationResponse(array $messages): array
     {
-        $err = $this->errorResponse("The given data was invalid.", 422);
+        $err = $this->errorResponse('The given data was invalid.', 422);
         $err['error']['validation'] = $messages;
+
         return $err;
     }
+
     /**
      * Get an approved API auth header.
      */
     protected function apiAuthHeader(): array
     {
         return [
-            "Authorization" => "Token {$this->apiTokenId}:{$this->apiTokenSecret}"
+            'Authorization' => "Token {$this->apiTokenId}:{$this->apiTokenSecret}",
         ];
     }
-
-}
\ No newline at end of file
+}
index f88fc19044286d5c3e4b937745cff0511437ce13..fd953021d068af884a07145d1fbbb64935a74da5 100644 (file)
@@ -1,67 +1,72 @@
-<?php namespace Tests\Auth;
+<?php
 
+namespace Tests\Auth;
+
+use BookStack\Auth\Access\Mfa\MfaSession;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Page;
 use BookStack\Notifications\ConfirmEmail;
 use BookStack\Notifications\ResetPassword;
-use BookStack\Settings\SettingService;
-use DB;
-use Hash;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Notification;
-use Illuminate\Support\Str;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
+use Tests\TestResponse;
 
-class AuthTest extends BrowserKitTest
+class AuthTest extends TestCase
 {
-
     public function test_auth_working()
     {
-        $this->visit('/')
-            ->seePageIs('/login');
+        $this->get('/')->assertRedirect('/login');
     }
 
     public function test_login()
     {
-        $this->login('[email protected]', 'password')
-            ->seePageIs('/');
+        $this->login('[email protected]', 'password')->assertRedirect('/');
     }
 
     public function test_public_viewing()
     {
-        $settings = app(SettingService::class);
-        $settings->put('app-public', 'true');
-        $this->visit('/')
-            ->seePageIs('/')
-            ->see('Log In');
+        $this->setSettings(['app-public' => 'true']);
+        $this->get('/')
+            ->assertOk()
+            ->assertSee('Log in');
     }
 
     public function test_registration_showing()
     {
         // Ensure registration form is showing
         $this->setSettings(['registration-enabled' => 'true']);
-        $this->visit('/login')
-            ->see('Sign up')
-            ->click('Sign up')
-            ->seePageIs('/register');
+        $this->get('/login')
+            ->assertElementContains('a[href="' . url('/register') . '"]', 'Sign up');
     }
 
     public function test_normal_registration()
     {
         // Set settings and get user instance
-        $this->setSettings(['registration-enabled' => 'true']);
-        $user = factory(User::class)->make();
+        /** @var Role $registrationRole */
+        $registrationRole = Role::query()->first();
+        $this->setSettings(['registration-enabled' => 'true', 'registration-role' => $registrationRole->id]);
+        /** @var User $user */
+        $user = User::factory()->make();
 
         // Test form and ensure user is created
-        $this->visit('/register')
-            ->see('Sign Up')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/')
-            ->see($user->name)
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email]);
+        $this->get('/register')
+            ->assertSee('Sign Up')
+            ->assertElementContains('form[action="' . url('/register') . '"]', 'Create Account');
+
+        $resp = $this->post('/register', $user->only('password', 'name', 'email'));
+        $resp->assertRedirect('/');
+
+        $resp = $this->get('/');
+        $resp->assertOk();
+        $resp->assertSee($user->name);
+
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email]);
+
+        $user = User::query()->where('email', '=', $user->email)->first();
+        $this->assertEquals(1, $user->roles()->count());
+        $this->assertEquals($registrationRole->id, $user->roles()->first()->id);
     }
 
     public function test_empty_registration_redirects_back_with_errors()
@@ -70,36 +75,33 @@ class AuthTest extends BrowserKitTest
         $this->setSettings(['registration-enabled' => 'true']);
 
         // Test form and ensure user is created
-        $this->visit('/register')
-            ->press('Create Account')
-            ->see('The name field is required')
-            ->seePageIs('/register');
+        $this->get('/register');
+        $this->post('/register', [])->assertRedirect('/register');
+        $this->get('/register')->assertSee('The name field is required');
     }
 
     public function test_registration_validation()
     {
         $this->setSettings(['registration-enabled' => 'true']);
 
-        $this->visit('/register')
-            ->type('1', '#name')
-            ->type('1', '#email')
-            ->type('1', '#password')
-            ->press('Create Account')
-            ->see('The name must be at least 2 characters.')
-            ->see('The email must be a valid email address.')
-            ->see('The password must be at least 8 characters.')
-            ->seePageIs('/register');
+        $this->get('/register');
+        $resp = $this->followingRedirects()->post('/register', [
+            'name'     => '1',
+            'email'    => '1',
+            'password' => '1',
+        ]);
+        $resp->assertSee('The name must be at least 2 characters.');
+        $resp->assertSee('The email must be a valid email address.');
+        $resp->assertSee('The password must be at least 8 characters.');
     }
 
     public function test_sign_up_link_on_login()
     {
-        $this->visit('/login')
-            ->dontSee('Sign up');
+        $this->get('/login')->assertDontSee('Sign up');
 
         $this->setSettings(['registration-enabled' => 'true']);
 
-        $this->visit('/login')
-            ->see('Sign up');
+        $this->get('/login')->assertSee('Sign up');
     }
 
     public function test_confirmed_registration()
@@ -109,294 +111,219 @@ class AuthTest extends BrowserKitTest
 
         // Set settings and get user instance
         $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']);
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
 
         // Go through registration process
-        $this->visit('/register')
-            ->see('Sign Up')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register/confirm')
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
+        $resp = $this->post('/register', $user->only('name', 'email', 'password'));
+        $resp->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
 
         // Ensure notification sent
-        $dbUser = User::where('email', '=', $user->email)->first();
+        /** @var User $dbUser */
+        $dbUser = User::query()->where('email', '=', $user->email)->first();
         Notification::assertSentTo($dbUser, ConfirmEmail::class);
 
         // Test access and resend confirmation email
-        $this->login($user->email, $user->password)
-            ->seePageIs('/register/confirm/awaiting')
-            ->see('Resend')
-            ->visit('/books')
-            ->seePageIs('/register/confirm/awaiting')
-            ->press('Resend Confirmation Email');
+        $resp = $this->login($user->email, $user->password);
+        $resp->assertRedirect('/register/confirm/awaiting');
+
+        $resp = $this->get('/register/confirm/awaiting');
+        $resp->assertElementContains('form[action="' . url('/register/confirm/resend') . '"]', 'Resend');
+
+        $this->get('/books')->assertRedirect('/login');
+        $this->post('/register/confirm/resend', $user->only('email'));
 
         // Get confirmation and confirm notification matches
         $emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first();
-        Notification::assertSentTo($dbUser, ConfirmEmail::class, function($notification, $channels) use ($emailConfirmation) {
+        Notification::assertSentTo($dbUser, ConfirmEmail::class, function ($notification, $channels) use ($emailConfirmation) {
             return $notification->token === $emailConfirmation->token;
         });
-        
+
         // Check confirmation email confirmation activation.
-        $this->visit('/register/confirm/' . $emailConfirmation->token)
-            ->seePageIs('/')
-            ->see($user->name)
-            ->notSeeInDatabase('email_confirmations', ['token' => $emailConfirmation->token])
-            ->seeInDatabase('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]);
+        $this->get('/register/confirm/' . $emailConfirmation->token)->assertRedirect('/login');
+        $this->get('/login')->assertSee('Your email has been confirmed! You should now be able to login using this email address.');
+        $this->assertDatabaseMissing('email_confirmations', ['token' => $emailConfirmation->token]);
+        $this->assertDatabaseHas('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]);
     }
 
     public function test_restricted_registration()
     {
         $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true', 'registration-restrict' => 'example.com']);
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
+
         // Go through registration process
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register')
-            ->dontSeeInDatabase('users', ['email' => $user->email])
-            ->see('That email domain does not have access to this application');
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register');
+        $resp = $this->get('/register');
+        $resp->assertSee('That email domain does not have access to this application');
+        $this->assertDatabaseMissing('users', $user->only('email'));
 
         $user->email = '[email protected]';
 
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register/confirm')
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
-
-        $this->visit('/')
-            ->seePageIs('/register/confirm/awaiting');
-
-        auth()->logout();
-
-        $this->visit('/')->seePageIs('/login')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Log In')
-            ->seePageIs('/register/confirm/awaiting')
-            ->seeText('Email Address Not Confirmed');
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
+
+        $this->assertNull(auth()->user());
+
+        $this->get('/')->assertRedirect('/login');
+        $resp = $this->followingRedirects()->post('/login', $user->only('email', 'password'));
+        $resp->assertSee('Email Address Not Confirmed');
+        $this->assertNull(auth()->user());
     }
 
     public function test_restricted_registration_with_confirmation_disabled()
     {
         $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'false', 'registration-restrict' => 'example.com']);
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
+
         // Go through registration process
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register')
-            ->dontSeeInDatabase('users', ['email' => $user->email])
-            ->see('That email domain does not have access to this application');
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register');
+        $this->assertDatabaseMissing('users', $user->only('email'));
+        $this->get('/register')->assertSee('That email domain does not have access to this application');
 
         $user->email = '[email protected]';
 
-        $this->visit('/register')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Create Account')
-            ->seePageIs('/register/confirm')
-            ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
-
-        $this->visit('/')
-            ->seePageIs('/register/confirm/awaiting');
-
-        auth()->logout();
-        $this->visit('/')->seePageIs('/login')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Log In')
-            ->seePageIs('/register/confirm/awaiting')
-            ->seeText('Email Address Not Confirmed');
-    }
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
 
-    public function test_user_creation()
-    {
-        /** @var User $user */
-        $user = factory(User::class)->make();
-        $adminRole = Role::getRole('admin');
-
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click('Add New User')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->check("roles[{$adminRole->id}]")
-            ->type($user->password, '#password')
-            ->type($user->password, '#password-confirm')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', $user->only(['name', 'email']))
-            ->see($user->name);
-
-        $user->refresh();
-        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
-    }
+        $this->assertNull(auth()->user());
 
-    public function test_user_updating()
-    {
-        $user = $this->getNormalUser();
-        $password = $user->password;
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click($user->name)
-            ->seePageIs('/settings/users/' . $user->id)
-            ->see($user->email)
-            ->type('Barry Scott', '#name')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password])
-            ->notSeeInDatabase('users', ['name' => $user->name]);
-
-        $user->refresh();
-        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
+        $this->get('/')->assertRedirect('/login');
+        $resp = $this->post('/login', $user->only('email', 'password'));
+        $resp->assertRedirect('/register/confirm/awaiting');
+        $this->get('/register/confirm/awaiting')->assertSee('Email Address Not Confirmed');
+        $this->assertNull(auth()->user());
     }
 
-    public function test_user_password_update()
+    public function test_registration_role_unset_by_default()
     {
-        $user = $this->getNormalUser();
-        $userProfilePage = '/settings/users/' . $user->id;
-        $this->asAdmin()
-            ->visit($userProfilePage)
-            ->type('newpassword', '#password')
-            ->press('Save')
-            ->seePageIs($userProfilePage)
-            ->see('Password confirmation required')
-
-            ->type('newpassword', '#password')
-            ->type('newpassword', '#password-confirm')
-            ->press('Save')
-            ->seePageIs('/settings/users');
-
-            $userPassword = User::find($user->id)->password;
-            $this->assertTrue(Hash::check('newpassword', $userPassword));
-    }
+        $this->assertFalse(setting('registration-role'));
 
-    public function test_user_deletion()
-    {
-        $userDetails = factory(User::class)->make();
-        $user = $this->getEditor($userDetails->toArray());
-
-        $this->asAdmin()
-            ->visit('/settings/users/' . $user->id)
-            ->click('Delete User')
-            ->see($user->name)
-            ->press('Confirm')
-            ->seePageIs('/settings/users')
-            ->notSeeInDatabase('users', ['name' => $user->name]);
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertElementContains('select[name="setting-registration-role"] option[value="0"][selected]', '-- None --');
     }
 
-    public function test_user_cannot_be_deleted_if_last_admin()
+    public function test_logout()
     {
-        $adminRole = Role::getRole('admin');
-
-        // Delete all but one admin user if there are more than one
-        $adminUsers = $adminRole->users;
-        if (count($adminUsers) > 1) {
-            foreach ($adminUsers->splice(1) as $user) {
-                $user->delete();
-            }
-        }
-
-        // Ensure we currently only have 1 admin user
-        $this->assertEquals(1, $adminRole->users()->count());
-        $user = $adminRole->users->first();
-
-        $this->asAdmin()->visit('/settings/users/' . $user->id)
-            ->click('Delete User')
-            ->press('Confirm')
-            ->seePageIs('/settings/users/' . $user->id)
-            ->see('You cannot delete the only admin');
+        $this->asAdmin()->get('/')->assertOk();
+        $this->post('/logout')->assertRedirect('/');
+        $this->get('/')->assertRedirect('/login');
     }
 
-    public function test_logout()
+    public function test_mfa_session_cleared_on_logout()
     {
-        $this->asAdmin()
-            ->visit('/')
-            ->seePageIs('/')
-            ->visit('/logout')
-            ->visit('/')
-            ->seePageIs('/login');
+        $user = $this->getEditor();
+        $mfaSession = $this->app->make(MfaSession::class);
+
+        $mfaSession->markVerifiedForUser($user);
+        $this->assertTrue($mfaSession->isVerifiedForUser($user));
+
+        $this->asAdmin()->post('/logout');
+        $this->assertFalse($mfaSession->isVerifiedForUser($user));
     }
 
     public function test_reset_password_flow()
     {
         Notification::fake();
 
-        $this->visit('/login')->click('Forgot Password?')
-            ->seePageIs('/password/email')
-            ->type('[email protected]', 'email')
-            ->press('Send Reset Link')
-            ->see('A password reset link will be sent to [email protected] if that email address is found in the system.');
+        $this->get('/login')
+            ->assertElementContains('a[href="' . url('/password/email') . '"]', 'Forgot Password?');
+
+        $this->get('/password/email')
+            ->assertElementContains('form[action="' . url('/password/email') . '"]', 'Send Reset Link');
 
-        $this->seeInDatabase('password_resets', [
-            'email' => '[email protected]'
+        $resp = $this->post('/password/email', [
+            'email' => '[email protected]',
         ]);
+        $resp->assertRedirect('/password/email');
 
-        $user = User::where('email', '=', '[email protected]')->first();
+        $resp = $this->get('/password/email');
+        $resp->assertSee('A password reset link will be sent to [email protected] if that email address is found in the system.');
+
+        $this->assertDatabaseHas('password_resets', [
+            'email' => '[email protected]',
+        ]);
+
+        /** @var User $user */
+        $user = User::query()->where('email', '=', '[email protected]')->first();
 
         Notification::assertSentTo($user, ResetPassword::class);
         $n = Notification::sent($user, ResetPassword::class);
 
-        $this->visit('/password/reset/' . $n->first()->token)
-            ->see('Reset Password')
-            ->submitForm('Reset Password', [
-                'email' => '[email protected]',
-                'password' => 'randompass',
-                'password_confirmation' => 'randompass'
-            ])->seePageIs('/')
-            ->see('Your password has been successfully reset');
+        $this->get('/password/reset/' . $n->first()->token)
+            ->assertOk()
+            ->assertSee('Reset Password');
+
+        $resp = $this->post('/password/reset', [
+            'email'                 => '[email protected]',
+            'password'              => 'randompass',
+            'password_confirmation' => 'randompass',
+            'token'                 => $n->first()->token,
+        ]);
+        $resp->assertRedirect('/');
+
+        $this->get('/')->assertSee('Your password has been successfully reset');
     }
 
     public function test_reset_password_flow_shows_success_message_even_if_wrong_password_to_prevent_user_discovery()
     {
-        $this->visit('/login')->click('Forgot Password?')
-            ->seePageIs('/password/email')
-            ->type('[email protected]', 'email')
-            ->press('Send Reset Link')
-            ->see('A password reset link will be sent to [email protected] if that email address is found in the system.')
-            ->dontSee('We can\'t find a user');
-
-
-        $this->visit('/password/reset/arandometokenvalue')
-            ->see('Reset Password')
-            ->submitForm('Reset Password', [
-                'email' => '[email protected]',
-                'password' => 'randompass',
-                'password_confirmation' => 'randompass'
-            ])->followRedirects()
-            ->seePageIs('/password/reset/arandometokenvalue')
-            ->dontSee('We can\'t find a user')
-            ->see('The password reset token is invalid for this email address.');
+        $this->get('/password/email');
+        $resp = $this->followingRedirects()->post('/password/email', [
+            'email' => '[email protected]',
+        ]);
+        $resp->assertSee('A password reset link will be sent to [email protected] if that email address is found in the system.');
+        $resp->assertDontSee('We can\'t find a user');
+
+        $this->get('/password/reset/arandometokenvalue')->assertSee('Reset Password');
+        $resp = $this->post('/password/reset', [
+            'email'                 => '[email protected]',
+            'password'              => 'randompass',
+            'password_confirmation' => 'randompass',
+            'token'                 => 'arandometokenvalue',
+        ]);
+        $resp->assertRedirect('/password/reset/arandometokenvalue');
+
+        $this->get('/password/reset/arandometokenvalue')
+            ->assertDontSee('We can\'t find a user')
+            ->assertSee('The password reset token is invalid for this email address.');
     }
 
     public function test_reset_password_page_shows_sign_links()
     {
         $this->setSettings(['registration-enabled' => 'true']);
-        $this->visit('/password/email')
-            ->seeLink('Log in')
-            ->seeLink('Sign up');
+        $this->get('/password/email')
+            ->assertElementContains('a', 'Log in')
+            ->assertElementContains('a', 'Sign up');
+    }
+
+    public function test_reset_password_request_is_throttled()
+    {
+        $editor = $this->getEditor();
+        Notification::fake();
+        $this->get('/password/email');
+        $this->followingRedirects()->post('/password/email', [
+            'email' => $editor->email,
+        ]);
+
+        $resp = $this->followingRedirects()->post('/password/email', [
+            'email' => $editor->email,
+        ]);
+        Notification::assertTimesSent(1, ResetPassword::class);
+        $resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.');
     }
 
     public function test_login_redirects_to_initially_requested_url_correctly()
     {
         config()->set('app.url', 'http://localhost');
+        /** @var Page $page */
         $page = Page::query()->first();
 
-        $this->visit($page->getUrl())
-            ->seePageUrlIs(url('/login'));
+        $this->get($page->getUrl())->assertRedirect(url('/login'));
         $this->login('[email protected]', 'password')
-            ->seePageUrlIs($page->getUrl());
+            ->assertRedirect($page->getUrl());
     }
 
     public function test_login_intended_redirect_does_not_redirect_to_external_pages()
@@ -407,7 +334,15 @@ class AuthTest extends BrowserKitTest
         $this->get('/login', ['referer' => 'https://example.com']);
         $login = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
 
-        $login->assertRedirectedTo('http://localhost');
+        $login->assertRedirect('http://localhost');
+    }
+
+    public function test_login_intended_redirect_does_not_factor_mfa_routes()
+    {
+        $this->get('/books')->assertRedirect('/login');
+        $this->get('/mfa/setup')->assertRedirect('/login');
+        $login = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
+        $login->assertRedirect('/books');
     }
 
     public function test_login_authenticates_admins_on_all_guards()
@@ -416,6 +351,7 @@ class AuthTest extends BrowserKitTest
         $this->assertTrue(auth()->check());
         $this->assertTrue(auth('ldap')->check());
         $this->assertTrue(auth('saml2')->check());
+        $this->assertTrue(auth('oidc')->check());
     }
 
     public function test_login_authenticates_nonadmins_on_default_guard_only()
@@ -428,6 +364,7 @@ class AuthTest extends BrowserKitTest
         $this->assertTrue(auth()->check());
         $this->assertFalse(auth('ldap')->check());
         $this->assertFalse(auth('saml2')->check());
+        $this->assertFalse(auth('oidc')->check());
     }
 
     public function test_failed_logins_are_logged_when_message_configured()
@@ -442,14 +379,25 @@ class AuthTest extends BrowserKitTest
         $this->assertFalse($log->hasWarningThatContains('Failed login for [email protected]'));
     }
 
+    public function test_logged_in_user_with_unconfirmed_email_is_logged_out()
+    {
+        $this->setSettings(['registration-confirmation' => 'true']);
+        $user = $this->getEditor();
+        $user->email_confirmed = false;
+        $user->save();
+
+        auth()->login($user);
+        $this->assertTrue(auth()->check());
+
+        $this->get('/books')->assertRedirect('/');
+        $this->assertFalse(auth()->check());
+    }
+
     /**
-     * Perform a login
+     * Perform a login.
      */
-    protected function login(string $email, string $password): AuthTest
+    protected function login(string $email, string $password): TestResponse
     {
-        return $this->visit('/login')
-            ->type($email, '#email')
-            ->type($password, '#password')
-            ->press('Log In');
+        return $this->post('/login', compact('email', 'password'));
     }
 }
index 840dfd630eeeea91c1cf3a8dd9e98b6d7aa6fb97..d00e8cf15df4f1de9565967bde4016d6d3b3273f 100644 (file)
@@ -1,16 +1,17 @@
-<?php namespace Tests\Auth;
+<?php
 
+namespace Tests\Auth;
+
+use BookStack\Auth\Access\Ldap;
 use BookStack\Auth\Access\LdapService;
 use BookStack\Auth\Role;
-use BookStack\Auth\Access\Ldap;
 use BookStack\Auth\User;
-use BookStack\Exceptions\LdapException;
 use Mockery\MockInterface;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
+use Tests\TestResponse;
 
-class LdapTest extends BrowserKitTest
+class LdapTest extends TestCase
 {
-
     /**
      * @var MockInterface
      */
@@ -19,26 +20,29 @@ class LdapTest extends BrowserKitTest
     protected $mockUser;
     protected $resourceId = 'resource-test';
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
-        if (!defined('LDAP_OPT_REFERRALS')) define('LDAP_OPT_REFERRALS', 1);
+        if (!defined('LDAP_OPT_REFERRALS')) {
+            define('LDAP_OPT_REFERRALS', 1);
+        }
         config()->set([
-            'auth.method' => 'ldap',
-            'auth.defaults.guard' => 'ldap',
-            'services.ldap.base_dn' => 'dc=ldap,dc=local',
-            'services.ldap.email_attribute' => 'mail',
+            'auth.method'                          => 'ldap',
+            'auth.defaults.guard'                  => 'ldap',
+            'services.ldap.base_dn'                => 'dc=ldap,dc=local',
+            'services.ldap.email_attribute'        => 'mail',
             'services.ldap.display_name_attribute' => 'cn',
-            'services.ldap.id_attribute' => 'uid',
-            'services.ldap.user_to_groups' => false,
-            'services.ldap.version' => '3',
-            'services.ldap.user_filter' => '(&(uid=${user}))',
-            'services.ldap.follow_referrals' => false,
-            'services.ldap.tls_insecure' => false,
+            'services.ldap.id_attribute'           => 'uid',
+            'services.ldap.user_to_groups'         => false,
+            'services.ldap.version'                => '3',
+            'services.ldap.user_filter'            => '(&(uid=${user}))',
+            'services.ldap.follow_referrals'       => false,
+            'services.ldap.tls_insecure'           => false,
+            'services.ldap.thumbnail_attribute'    => null,
         ]);
         $this->mockLdap = \Mockery::mock(Ldap::class);
         $this->app[Ldap::class] = $this->mockLdap;
-        $this->mockUser = factory(User::class)->make();
+        $this->mockUser = User::factory()->make();
     }
 
     protected function runFailedAuthLogin()
@@ -51,25 +55,24 @@ class LdapTest extends BrowserKitTest
 
     protected function mockEscapes($times = 1)
     {
-        $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function($val) {
+        $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function ($val) {
             return ldap_escape($val);
         });
     }
 
     protected function mockExplodes($times = 1)
     {
-        $this->mockLdap->shouldReceive('explodeDn')->times($times)->andReturnUsing(function($dn, $withAttrib) {
+        $this->mockLdap->shouldReceive('explodeDn')->times($times)->andReturnUsing(function ($dn, $withAttrib) {
             return ldap_explode_dn($dn, $withAttrib);
         });
     }
 
-    protected function mockUserLogin()
+    protected function mockUserLogin(?string $email = null): TestResponse
     {
-        return $this->visit('/login')
-            ->see('Username')
-            ->type($this->mockUser->name, '#username')
-            ->type($this->mockUser->password, '#password')
-            ->press('Log In');
+        return $this->post('/login', [
+            'username' => $this->mockUser->name,
+            'password' => $this->mockUser->password,
+        ] + ($email ? ['email' => $email] : []));
     }
 
     /**
@@ -92,24 +95,30 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
                 'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')]
+                'cn'  => [$this->mockUser->name],
+                'dn'  => ['dc=test' . config('services.ldap.base_dn')],
             ]]);
 
-        $this->mockUserLogin()
-            ->seePageIs('/login')->see('Please enter an email to use for this account.');
-
-        $this->type($this->mockUser->email, '#email')
-            ->press('Log In')
-            ->seePageIs('/')
-            ->see($this->mockUser->name)
-            ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name]);
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/login');
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Please enter an email to use for this account.');
+        $resp->assertSee($this->mockUser->name);
+
+        $resp = $this->followingRedirects()->mockUserLogin($this->mockUser->email);
+        $resp->assertElementExists('#home-default');
+        $resp->assertSee($this->mockUser->name);
+        $this->assertDatabaseHas('users', [
+            'email'            => $this->mockUser->email,
+            'email_confirmed'  => false,
+            'external_auth_id' => $this->mockUser->name,
+        ]);
     }
 
     public function test_email_domain_restriction_active_on_new_ldap_login()
     {
         $this->setSettings([
-            'registration-restrict' => 'testing.com'
+            'registration-restrict' => 'testing.com',
         ]);
 
         $this->commonLdapMocks(1, 1, 2, 4, 2);
@@ -117,21 +126,20 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
                 'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')]
+                'cn'  => [$this->mockUser->name],
+                'dn'  => ['dc=test' . config('services.ldap.base_dn')],
             ]]);
 
-        $this->mockUserLogin()
-            ->seePageIs('/login')
-            ->see('Please enter an email to use for this account.');
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSee('Please enter an email to use for this account.');
 
         $email = '[email protected]';
+        $resp = $this->mockUserLogin($email);
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSee('That email domain does not have access to this application');
 
-        $this->type($email, '#email')
-            ->press('Log In')
-            ->seePageIs('/login')
-            ->see('That email domain does not have access to this application')
-            ->dontSeeInDatabase('users', ['email' => $email]);
+        $this->assertDatabaseMissing('users', ['email' => $email]);
     }
 
     public function test_login_works_when_no_uid_provided_by_ldap_server()
@@ -142,15 +150,15 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'cn' => [$this->mockUser->name],
-                'dn' => $ldapDn,
-                'mail' => [$this->mockUser->email]
+                'cn'   => [$this->mockUser->name],
+                'dn'   => $ldapDn,
+                'mail' => [$this->mockUser->email],
             ]]);
 
-        $this->mockUserLogin()
-            ->seePageIs('/')
-            ->see($this->mockUser->name)
-            ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/');
+        $this->followRedirects($resp)->assertSee($this->mockUser->name);
+        $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
     }
 
     public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly()
@@ -162,17 +170,16 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'cn' => [$this->mockUser->name],
-                'dn' => $ldapDn,
+                'cn'           => [$this->mockUser->name],
+                'dn'           => $ldapDn,
                 'my_custom_id' => ['cooluser456'],
-                'mail' => [$this->mockUser->email]
+                'mail'         => [$this->mockUser->email],
             ]]);
 
-
-        $this->mockUserLogin()
-            ->seePageIs('/')
-            ->see($this->mockUser->name)
-            ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/');
+        $this->followRedirects($resp)->assertSee($this->mockUser->name);
+        $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
     }
 
     public function test_initial_incorrect_credentials()
@@ -182,14 +189,15 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
                 'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')]
+                'cn'  => [$this->mockUser->name],
+                'dn'  => ['dc=test' . config('services.ldap.base_dn')],
             ]]);
         $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false);
 
-        $this->mockUserLogin()
-            ->seePageIs('/login')->see('These credentials do not match our records.')
-            ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]);
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
+        $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
     }
 
     public function test_login_not_found_username()
@@ -199,62 +207,72 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 0]);
 
-        $this->mockUserLogin()
-            ->seePageIs('/login')->see('These credentials do not match our records.')
-            ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]);
+        $resp = $this->mockUserLogin();
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSee('These credentials do not match our records.');
+        $this->assertDatabaseMissing('users', ['external_auth_id' => $this->mockUser->name]);
     }
 
-
     public function test_create_user_form()
     {
-        $this->asAdmin()->visit('/settings/users/create')
-            ->dontSee('Password')
-            ->type($this->mockUser->name, '#name')
-            ->type($this->mockUser->email, '#email')
-            ->press('Save')
-            ->see('The external auth id field is required.')
-            ->type($this->mockUser->name, '#external_auth_id')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
+        $userForm = $this->asAdmin()->get('/settings/users/create');
+        $userForm->assertDontSee('Password');
+
+        $save = $this->post('/settings/users/create', [
+            'name'  => $this->mockUser->name,
+            'email' => $this->mockUser->email,
+        ]);
+        $save->assertSessionHasErrors(['external_auth_id' => 'The external auth id field is required.']);
+
+        $save = $this->post('/settings/users/create', [
+            'name'             => $this->mockUser->name,
+            'email'            => $this->mockUser->email,
+            'external_auth_id' => $this->mockUser->name,
+        ]);
+        $save->assertRedirect('/settings/users');
+        $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
     }
 
     public function test_user_edit_form()
     {
         $editUser = $this->getNormalUser();
-        $this->asAdmin()->visit('/settings/users/' . $editUser->id)
-            ->see('Edit User')
-            ->dontSee('Password')
-            ->type('test_auth_id', '#external_auth_id')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
+        $editPage = $this->asAdmin()->get("/settings/users/{$editUser->id}");
+        $editPage->assertSee('Edit User');
+        $editPage->assertDontSee('Password');
+
+        $update = $this->put("/settings/users/{$editUser->id}", [
+            'name'             => $editUser->name,
+            'email'            => $editUser->email,
+            'external_auth_id' => 'test_auth_id',
+        ]);
+        $update->assertRedirect('/settings/users');
+        $this->assertDatabaseHas('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
     }
 
     public function test_registration_disabled()
     {
-        $this->visit('/register')
-            ->seePageIs('/login');
+        $this->followingRedirects()->get('/register')->assertElementContains('#content', 'Log In');
     }
 
     public function test_non_admins_cannot_change_auth_id()
     {
         $testUser = $this->getNormalUser();
-        $this->actingAs($testUser)->visit('/settings/users/' . $testUser->id)
-            ->dontSee('External Authentication');
+        $this->actingAs($testUser)
+            ->get('/settings/users/' . $testUser->id)
+            ->assertDontSee('External Authentication');
     }
 
     public function test_login_maps_roles_and_retains_existing_roles()
     {
-        $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
-        $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']);
-        $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']);
+        $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
+        $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
+        $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
         $this->mockUser->attachRole($existingRole);
 
         app('config')->set([
-            'services.ldap.user_to_groups' => true,
-            'services.ldap.group_attribute' => 'memberOf',
+            'services.ldap.user_to_groups'     => true,
+            'services.ldap.group_attribute'    => 'memberOf',
             'services.ldap.remove_from_groups' => false,
         ]);
 
@@ -262,44 +280,44 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
-                'mail' => [$this->mockUser->email],
+                'uid'      => [$this->mockUser->name],
+                'cn'       => [$this->mockUser->name],
+                'dn'       => ['dc=test' . config('services.ldap.base_dn')],
+                'mail'     => [$this->mockUser->email],
                 'memberof' => [
                     'count' => 2,
-                    0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
-                    1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
-                ]
+                    0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
+                    1       => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
+                ],
             ]]);
 
-        $this->mockUserLogin()->seePageIs('/');
+        $this->mockUserLogin()->assertRedirect('/');
 
         $user = User::where('email', $this->mockUser->email)->first();
-        $this->seeInDatabase('role_user', [
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive->id
+            'role_id' => $roleToReceive->id,
         ]);
-        $this->seeInDatabase('role_user', [
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive2->id
+            'role_id' => $roleToReceive2->id,
         ]);
-        $this->seeInDatabase('role_user', [
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $existingRole->id
+            'role_id' => $existingRole->id,
         ]);
     }
 
     public function test_login_maps_roles_and_removes_old_roles_if_set()
     {
-        $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
-        $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']);
+        $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
+        $existingRole = Role::factory()->create(['display_name' => 'ldaptester-existing']);
         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
         $this->mockUser->attachRole($existingRole);
 
         app('config')->set([
-            'services.ldap.user_to_groups' => true,
-            'services.ldap.group_attribute' => 'memberOf',
+            'services.ldap.user_to_groups'     => true,
+            'services.ldap.group_attribute'    => 'memberOf',
             'services.ldap.remove_from_groups' => true,
         ]);
 
@@ -307,44 +325,44 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
-                'mail' => [$this->mockUser->email],
+                'uid'      => [$this->mockUser->name],
+                'cn'       => [$this->mockUser->name],
+                'dn'       => ['dc=test' . config('services.ldap.base_dn')],
+                'mail'     => [$this->mockUser->email],
                 'memberof' => [
                     'count' => 1,
-                    0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
-                ]
+                    0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
+                ],
             ]]);
 
-        $this->mockUserLogin()->seePageIs('/');
+        $this->mockUserLogin()->assertRedirect('/');
 
-        $user = User::where('email', $this->mockUser->email)->first();
-        $this->seeInDatabase('role_user', [
+        $user = User::query()->where('email', $this->mockUser->email)->first();
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive->id
+            'role_id' => $roleToReceive->id,
         ]);
-        $this->dontSeeInDatabase('role_user', [
+        $this->assertDatabaseMissing('role_user', [
             'user_id' => $user->id,
-            'role_id' => $existingRole->id
+            'role_id' => $existingRole->id,
         ]);
     }
 
     public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
     {
-        $role = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
-        $this->asAdmin()->visit('/settings/roles/' . $role->id)
-            ->see('ex-auth-a');
+        $role = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
+        $this->asAdmin()->get('/settings/roles/' . $role->id)
+            ->assertSee('ex-auth-a');
     }
 
     public function test_login_maps_roles_using_external_auth_ids_if_set()
     {
-        $roleToReceive = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
-        $roleToNotReceive = factory(Role::class)->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
+        $roleToReceive = Role::factory()->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
+        $roleToNotReceive = Role::factory()->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
 
         app('config')->set([
-            'services.ldap.user_to_groups' => true,
-            'services.ldap.group_attribute' => 'memberOf',
+            'services.ldap.user_to_groups'     => true,
+            'services.ldap.group_attribute'    => 'memberOf',
             'services.ldap.remove_from_groups' => true,
         ]);
 
@@ -352,40 +370,40 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
-                'mail' => [$this->mockUser->email],
+                'uid'      => [$this->mockUser->name],
+                'cn'       => [$this->mockUser->name],
+                'dn'       => ['dc=test' . config('services.ldap.base_dn')],
+                'mail'     => [$this->mockUser->email],
                 'memberof' => [
                     'count' => 1,
-                    0 => "cn=ex-auth-a,ou=groups,dc=example,dc=com",
-                ]
+                    0       => 'cn=ex-auth-a,ou=groups,dc=example,dc=com',
+                ],
             ]]);
 
-        $this->mockUserLogin()->seePageIs('/');
+        $this->mockUserLogin()->assertRedirect('/');
 
-        $user = User::where('email', $this->mockUser->email)->first();
-        $this->seeInDatabase('role_user', [
+        $user = User::query()->where('email', $this->mockUser->email)->first();
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive->id
+            'role_id' => $roleToReceive->id,
         ]);
-        $this->dontSeeInDatabase('role_user', [
+        $this->assertDatabaseMissing('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToNotReceive->id
+            'role_id' => $roleToNotReceive->id,
         ]);
     }
 
     public function test_login_group_mapping_does_not_conflict_with_default_role()
     {
-        $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
-        $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']);
+        $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
+        $roleToReceive2 = Role::factory()->create(['display_name' => 'LdapTester Second']);
         $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
 
         setting()->put('registration-role', $roleToReceive->id);
 
         app('config')->set([
-            'services.ldap.user_to_groups' => true,
-            'services.ldap.group_attribute' => 'memberOf',
+            'services.ldap.user_to_groups'     => true,
+            'services.ldap.group_attribute'    => 'memberOf',
             'services.ldap.remove_from_groups' => true,
         ]);
 
@@ -393,60 +411,59 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
-                'mail' => [$this->mockUser->email],
+                'uid'      => [$this->mockUser->name],
+                'cn'       => [$this->mockUser->name],
+                'dn'       => ['dc=test' . config('services.ldap.base_dn')],
+                'mail'     => [$this->mockUser->email],
                 'memberof' => [
                     'count' => 2,
-                    0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
-                    1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
-                ]
+                    0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
+                    1       => 'cn=ldaptester-second,ou=groups,dc=example,dc=com',
+                ],
             ]]);
 
-        $this->mockUserLogin()->seePageIs('/');
+        $this->mockUserLogin()->assertRedirect('/');
 
-        $user = User::where('email', $this->mockUser->email)->first();
-        $this->seeInDatabase('role_user', [
+        $user = User::query()->where('email', $this->mockUser->email)->first();
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive->id
+            'role_id' => $roleToReceive->id,
         ]);
-        $this->seeInDatabase('role_user', [
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive2->id
+            'role_id' => $roleToReceive2->id,
         ]);
     }
 
     public function test_login_uses_specified_display_name_attribute()
     {
         app('config')->set([
-            'services.ldap.display_name_attribute' => 'displayName'
+            'services.ldap.display_name_attribute' => 'displayName',
         ]);
 
         $this->commonLdapMocks(1, 1, 2, 4, 2);
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
-                'displayname' => 'displayNameAttribute'
+                'uid'         => [$this->mockUser->name],
+                'cn'          => [$this->mockUser->name],
+                'dn'          => ['dc=test' . config('services.ldap.base_dn')],
+                'displayname' => 'displayNameAttribute',
             ]]);
 
-        $this->mockUserLogin()
-            ->seePageIs('/login')->see('Please enter an email to use for this account.');
+        $this->mockUserLogin()->assertRedirect('/login');
+        $this->get('/login')->assertSee('Please enter an email to use for this account.');
 
-        $this->type($this->mockUser->email, '#email')
-            ->press('Log In')
-            ->seePageIs('/')
-            ->see('displayNameAttribute')
-            ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
+        $resp = $this->mockUserLogin($this->mockUser->email);
+        $resp->assertRedirect('/');
+        $this->get('/')->assertSee('displayNameAttribute');
+        $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
     }
 
     public function test_login_uses_default_display_name_attribute_if_specified_not_present()
     {
         app('config')->set([
-            'services.ldap.display_name_attribute' => 'displayName'
+            'services.ldap.display_name_attribute' => 'displayName',
         ]);
 
         $this->commonLdapMocks(1, 1, 2, 4, 2);
@@ -454,32 +471,36 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
                 'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')]
+                'cn'  => [$this->mockUser->name],
+                'dn'  => ['dc=test' . config('services.ldap.base_dn')],
             ]]);
 
-        $this->mockUserLogin()
-            ->seePageIs('/login')->see('Please enter an email to use for this account.');
-
-        $this->type($this->mockUser->email, '#email')
-            ->press('Log In')
-            ->seePageIs('/')
-            ->see($this->mockUser->name)
-            ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => $this->mockUser->name]);
+        $this->mockUserLogin()->assertRedirect('/login');
+        $this->get('/login')->assertSee('Please enter an email to use for this account.');
+
+        $resp = $this->mockUserLogin($this->mockUser->email);
+        $resp->assertRedirect('/');
+        $this->get('/')->assertSee($this->mockUser->name);
+        $this->assertDatabaseHas('users', [
+            'email'            => $this->mockUser->email,
+            'email_confirmed'  => false,
+            'external_auth_id' => $this->mockUser->name,
+            'name'             => $this->mockUser->name,
+        ]);
     }
 
     protected function checkLdapReceivesCorrectDetails($serverString, $expectedHost, $expectedPort)
     {
         app('config')->set([
-            'services.ldap.server' => $serverString
+            'services.ldap.server' => $serverString,
         ]);
 
         // Standard mocks
         $this->commonLdapMocks(0, 1, 1, 2, 1);
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)->andReturn(['count' => 1, 0 => [
             'uid' => [$this->mockUser->name],
-            'cn' => [$this->mockUser->name],
-            'dn' => ['dc=test' . config('services.ldap.base_dn')]
+            'cn'  => [$this->mockUser->name],
+            'dn'  => ['dc=test' . config('services.ldap.base_dn')],
         ]]);
 
         $this->mockLdap->shouldReceive('connect')->once()
@@ -545,16 +566,16 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
                 'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')]
+                'cn'  => [$this->mockUser->name],
+                'dn'  => ['dc=test' . config('services.ldap.base_dn')],
             ]]);
 
-        $this->post('/login', [
+        $resp = $this->post('/login', [
             'username' => $this->mockUser->name,
             'password' => $this->mockUser->password,
         ]);
-        $this->seeJsonStructure([
-            'details_from_ldap' => [],
+        $resp->assertJsonStructure([
+            'details_from_ldap'        => [],
             'details_bookstack_parsed' => [],
         ]);
     }
@@ -571,8 +592,8 @@ class LdapTest extends BrowserKitTest
         config()->set(['services.ldap.start_tls' => true]);
         $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
         $this->commonLdapMocks(1, 1, 0, 0, 0);
-        $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
-        $this->assertResponseStatus(500);
+        $resp = $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
+        $resp->assertStatus(500);
     }
 
     public function test_ldap_attributes_can_be_binary_decoded_if_marked()
@@ -584,8 +605,8 @@ class LdapTest extends BrowserKitTest
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn'])
             ->andReturn(['count' => 1, 0 => [
                 'uid' => [hex2bin('FFF8F7')],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')]
+                'cn'  => [$this->mockUser->name],
+                'dn'  => ['dc=test' . config('services.ldap.base_dn')],
             ]]);
 
         $details = $ldapService->getUserDetails('test');
@@ -598,67 +619,72 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$this->mockUser->name],
-                'cn' => [$this->mockUser->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
+                'uid'  => [$this->mockUser->name],
+                'cn'   => [$this->mockUser->name],
+                'dn'   => ['dc=test' . config('services.ldap.base_dn')],
                 'mail' => '[email protected]',
             ]], ['count' => 1, 0 => [
-                'uid' => ['Barry'],
-                'cn' => ['Scott'],
-                'dn' => ['dc=bscott' . config('services.ldap.base_dn')],
+                'uid'  => ['Barry'],
+                'cn'   => ['Scott'],
+                'dn'   => ['dc=bscott' . config('services.ldap.base_dn')],
                 'mail' => '[email protected]',
             ]]);
 
         // First user login
-        $this->mockUserLogin()->seePageIs('/');
+        $this->mockUserLogin()->assertRedirect('/');
 
         // Second user login
         auth()->logout();
-        $this->post('/login', ['username' => 'bscott', 'password' => 'pass'])->followRedirects();
-
-        $this->see('A user with the email [email protected] already exists but with different credentials');
+        $resp = $this->followingRedirects()->post('/login', ['username' => 'bscott', 'password' => 'pass']);
+        $resp->assertSee('A user with the email [email protected] already exists but with different credentials');
     }
 
     public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen()
     {
-        $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
-        $user = factory(User::class)->make();
+        $roleToReceive = Role::factory()->create(['display_name' => 'LdapTester']);
+        $user = User::factory()->make();
         setting()->put('registration-confirmation', 'true');
 
         app('config')->set([
-            'services.ldap.user_to_groups' => true,
-            'services.ldap.group_attribute' => 'memberOf',
+            'services.ldap.user_to_groups'     => true,
+            'services.ldap.group_attribute'    => 'memberOf',
             'services.ldap.remove_from_groups' => true,
         ]);
 
-        $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
+        $this->commonLdapMocks(1, 1, 6, 8, 6, 4);
         $this->mockLdap->shouldReceive('searchAndGetEntries')
-            ->times(3)
+            ->times(6)
             ->andReturn(['count' => 1, 0 => [
-                'uid' => [$user->name],
-                'cn' => [$user->name],
-                'dn' => ['dc=test' . config('services.ldap.base_dn')],
-                'mail' => [$user->email],
+                'uid'      => [$user->name],
+                'cn'       => [$user->name],
+                'dn'       => ['dc=test' . config('services.ldap.base_dn')],
+                'mail'     => [$user->email],
                 'memberof' => [
                     'count' => 1,
-                    0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
-                ]
+                    0       => 'cn=ldaptester,ou=groups,dc=example,dc=com',
+                ],
             ]]);
 
-        $this->mockUserLogin()->seePageIs('/register/confirm');
-        $this->seeInDatabase('users', [
-            'email' => $user->email,
+        $login = $this->followingRedirects()->mockUserLogin();
+        $login->assertSee('Thanks for registering!');
+        $this->assertDatabaseHas('users', [
+            'email'           => $user->email,
             'email_confirmed' => false,
         ]);
 
-        $user  = User::query()->where('email', '=', $user->email)->first();
-        $this->seeInDatabase('role_user', [
+        $user = User::query()->where('email', '=', $user->email)->first();
+        $this->assertDatabaseHas('role_user', [
             'user_id' => $user->id,
-            'role_id' => $roleToReceive->id
+            'role_id' => $roleToReceive->id,
         ]);
 
+        $this->assertNull(auth()->user());
+
         $homePage = $this->get('/');
-        $homePage->assertRedirectedTo('/register/confirm/awaiting');
+        $homePage->assertRedirect('/login');
+
+        $login = $this->followingRedirects()->mockUserLogin();
+        $login->assertSee('Email Address Not Confirmed');
     }
 
     public function test_failed_logins_are_logged_when_message_configured()
@@ -668,4 +694,28 @@ class LdapTest extends BrowserKitTest
         $this->runFailedAuthLogin();
         $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));
     }
+
+    public function test_thumbnail_attribute_used_as_user_avatar_if_configured()
+    {
+        config()->set(['services.ldap.thumbnail_attribute' => 'jpegPhoto']);
+
+        $this->commonLdapMocks(1, 1, 1, 2, 1);
+        $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
+        $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
+            ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
+            ->andReturn(['count' => 1, 0 => [
+                'cn'        => [$this->mockUser->name],
+                'dn'        => $ldapDn,
+                'jpegphoto' => [base64_decode('/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8Q
+EBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=')],
+                'mail' => [$this->mockUser->email],
+            ]]);
+
+        $this->mockUserLogin()
+            ->assertRedirect('/');
+
+        $user = User::query()->where('email', '=', $this->mockUser->email)->first();
+        $this->assertNotNull($user->avatar);
+        $this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
+    }
 }
diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php
new file mode 100644 (file)
index 0000000..fab9481
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+
+namespace Tests\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
+use PragmaRX\Google2FA\Google2FA;
+use Tests\TestCase;
+
+class MfaConfigurationTest extends TestCase
+{
+    public function test_totp_setup()
+    {
+        $editor = $this->getEditor();
+        $this->assertDatabaseMissing('mfa_values', ['user_id' => $editor->id]);
+
+        // Setup page state
+        $resp = $this->actingAs($editor)->get('/mfa/setup');
+        $resp->assertElementContains('a[href$="/mfa/totp/generate"]', 'Setup');
+
+        // Generate page access
+        $resp = $this->get('/mfa/totp/generate');
+        $resp->assertSee('Mobile App Setup');
+        $resp->assertSee('Verify Setup');
+        $resp->assertElementExists('form[action$="/mfa/totp/confirm"] button');
+        $this->assertSessionHas('mfa-setup-totp-secret');
+        $svg = $resp->getElementHtml('#main-content .card svg');
+
+        // Validation error, code should remain the same
+        $resp = $this->post('/mfa/totp/confirm', [
+            'code' => 'abc123',
+        ]);
+        $resp->assertRedirect('/mfa/totp/generate');
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('The provided code is not valid or has expired.');
+        $revisitSvg = $resp->getElementHtml('#main-content .card svg');
+        $this->assertTrue($svg === $revisitSvg);
+        $secret = decrypt(session()->get('mfa-setup-totp-secret'));
+
+        $resp->assertSee("?secret={$secret}&issuer=BookStack&algorithm=SHA1&digits=6&period=30");
+
+        // Successful confirmation
+        $google2fa = new Google2FA();
+        $otp = $google2fa->getCurrentOtp($secret);
+        $resp = $this->post('/mfa/totp/confirm', [
+            'code' => $otp,
+        ]);
+        $resp->assertRedirect('/mfa/setup');
+
+        // Confirmation of setup
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Multi-factor method successfully configured');
+        $resp->assertElementContains('a[href$="/mfa/totp/generate"]', 'Reconfigure');
+
+        $this->assertDatabaseHas('mfa_values', [
+            'user_id' => $editor->id,
+            'method'  => 'totp',
+        ]);
+        $this->assertFalse(session()->has('mfa-setup-totp-secret'));
+        $value = MfaValue::query()->where('user_id', '=', $editor->id)
+            ->where('method', '=', 'totp')->first();
+        $this->assertEquals($secret, decrypt($value->value));
+    }
+
+    public function test_backup_codes_setup()
+    {
+        $editor = $this->getEditor();
+        $this->assertDatabaseMissing('mfa_values', ['user_id' => $editor->id]);
+
+        // Setup page state
+        $resp = $this->actingAs($editor)->get('/mfa/setup');
+        $resp->assertElementContains('a[href$="/mfa/backup_codes/generate"]', 'Setup');
+
+        // Generate page access
+        $resp = $this->get('/mfa/backup_codes/generate');
+        $resp->assertSee('Backup Codes');
+        $resp->assertElementContains('form[action$="/mfa/backup_codes/confirm"]', 'Confirm and Enable');
+        $this->assertSessionHas('mfa-setup-backup-codes');
+        $codes = decrypt(session()->get('mfa-setup-backup-codes'));
+        // Check code format
+        $this->assertCount(16, $codes);
+        $this->assertEquals(16 * 11, strlen(implode('', $codes)));
+        // Check download link
+        $resp->assertSee(base64_encode(implode("\n\n", $codes)));
+
+        // Confirm submit
+        $resp = $this->post('/mfa/backup_codes/confirm');
+        $resp->assertRedirect('/mfa/setup');
+
+        // Confirmation of setup
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Multi-factor method successfully configured');
+        $resp->assertElementContains('a[href$="/mfa/backup_codes/generate"]', 'Reconfigure');
+
+        $this->assertDatabaseHas('mfa_values', [
+            'user_id' => $editor->id,
+            'method'  => 'backup_codes',
+        ]);
+        $this->assertFalse(session()->has('mfa-setup-backup-codes'));
+        $value = MfaValue::query()->where('user_id', '=', $editor->id)
+            ->where('method', '=', 'backup_codes')->first();
+        $this->assertEquals($codes, json_decode(decrypt($value->value)));
+    }
+
+    public function test_backup_codes_cannot_be_confirmed_if_not_previously_generated()
+    {
+        $resp = $this->asEditor()->post('/mfa/backup_codes/confirm');
+        $resp->assertStatus(500);
+    }
+
+    public function test_mfa_method_count_is_visible_on_user_edit_page()
+    {
+        $user = $this->getEditor();
+        $resp = $this->actingAs($this->getAdmin())->get($user->getEditUrl());
+        $resp->assertSee('0 methods configured');
+
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'test');
+        $resp = $this->get($user->getEditUrl());
+        $resp->assertSee('1 method configured');
+
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, 'test');
+        $resp = $this->get($user->getEditUrl());
+        $resp->assertSee('2 methods configured');
+    }
+
+    public function test_mfa_setup_link_only_shown_when_viewing_own_user_edit_page()
+    {
+        $admin = $this->getAdmin();
+        $resp = $this->actingAs($admin)->get($admin->getEditUrl());
+        $resp->assertElementExists('a[href$="/mfa/setup"]');
+
+        $resp = $this->actingAs($admin)->get($this->getEditor()->getEditUrl());
+        $resp->assertElementNotExists('a[href$="/mfa/setup"]');
+    }
+
+    public function test_mfa_indicator_shows_in_user_list()
+    {
+        $admin = $this->getAdmin();
+        User::query()->where('id', '!=', $admin->id)->delete();
+
+        $resp = $this->actingAs($admin)->get('/settings/users');
+        $resp->assertElementNotExists('[title="MFA Configured"] svg');
+
+        MfaValue::upsertWithValue($admin, MfaValue::METHOD_TOTP, 'test');
+        $resp = $this->actingAs($admin)->get('/settings/users');
+        $resp->assertElementExists('[title="MFA Configured"] svg');
+    }
+
+    public function test_remove_mfa_method()
+    {
+        $admin = $this->getAdmin();
+
+        MfaValue::upsertWithValue($admin, MfaValue::METHOD_TOTP, 'test');
+        $this->assertEquals(1, $admin->mfaValues()->count());
+        $resp = $this->actingAs($admin)->get('/mfa/setup');
+        $resp->assertElementExists('form[action$="/mfa/totp/remove"]');
+
+        $resp = $this->delete('/mfa/totp/remove');
+        $resp->assertRedirect('/mfa/setup');
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Multi-factor method successfully removed');
+
+        $this->assertActivityExists(ActivityType::MFA_REMOVE_METHOD);
+        $this->assertEquals(0, $admin->mfaValues()->count());
+    }
+
+    public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login()
+    {
+        $admin = $this->getAdmin();
+        /** @var Role $role */
+        $role = $admin->roles()->first();
+        $role->mfa_enforced = true;
+        $role->save();
+
+        $resp = $this->post('/login', ['email' => $admin->email, 'password' => 'password']);
+        $this->assertFalse(auth()->check());
+        $resp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/totp/generate');
+        $resp->assertSeeText('Mobile App Setup');
+        $resp->assertDontSee('otpauth://totp/BookStack:guest%40example.com', false);
+        $resp->assertSee('otpauth://totp/BookStack:admin%40admin.com', false);
+    }
+}
diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php
new file mode 100644 (file)
index 0000000..9a61062
--- /dev/null
@@ -0,0 +1,278 @@
+<?php
+
+namespace Tests\Auth;
+
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Auth\Access\Mfa\TotpService;
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
+use BookStack\Exceptions\StoppedAuthenticationException;
+use Illuminate\Support\Facades\Hash;
+use PragmaRX\Google2FA\Google2FA;
+use Tests\TestCase;
+use Tests\TestResponse;
+
+class MfaVerificationTest extends TestCase
+{
+    public function test_totp_verification()
+    {
+        [$user, $secret, $loginResp] = $this->startTotpLogin();
+        $loginResp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/verify');
+        $resp->assertSee('Verify Access');
+        $resp->assertSee('Enter the code, generated using your mobile app, below:');
+        $resp->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"][autofocus]');
+
+        $google2fa = new Google2FA();
+        $resp = $this->post('/mfa/totp/verify', [
+            'code' => $google2fa->getCurrentOtp($secret),
+        ]);
+        $resp->assertRedirect('/');
+        $this->assertEquals($user->id, auth()->user()->id);
+    }
+
+    public function test_totp_verification_fails_on_missing_invalid_code()
+    {
+        [$user, $secret, $loginResp] = $this->startTotpLogin();
+
+        $resp = $this->get('/mfa/verify');
+        $resp = $this->post('/mfa/totp/verify', [
+            'code' => '',
+        ]);
+        $resp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/verify');
+        $resp->assertSeeText('The code field is required.');
+        $this->assertNull(auth()->user());
+
+        $resp = $this->post('/mfa/totp/verify', [
+            'code' => '123321',
+        ]);
+        $resp->assertRedirect('/mfa/verify');
+        $resp = $this->get('/mfa/verify');
+
+        $resp->assertSeeText('The provided code is not valid or has expired.');
+        $this->assertNull(auth()->user());
+    }
+
+    public function test_backup_code_verification()
+    {
+        [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
+        $loginResp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/verify');
+        $resp->assertSee('Verify Access');
+        $resp->assertSee('Backup Code');
+        $resp->assertSee('Enter one of your remaining backup codes below:');
+        $resp->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
+
+        $resp = $this->post('/mfa/backup_codes/verify', [
+            'code' => $codes[1],
+        ]);
+
+        $resp->assertRedirect('/');
+        $this->assertEquals($user->id, auth()->user()->id);
+        // Ensure code no longer exists in available set
+        $userCodes = MfaValue::getValueForUser($user, MfaValue::METHOD_BACKUP_CODES);
+        $this->assertStringNotContainsString($codes[1], $userCodes);
+        $this->assertStringContainsString($codes[0], $userCodes);
+    }
+
+    public function test_backup_code_verification_fails_on_missing_or_invalid_code()
+    {
+        [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
+
+        $resp = $this->get('/mfa/verify');
+        $resp = $this->post('/mfa/backup_codes/verify', [
+            'code' => '',
+        ]);
+        $resp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/verify');
+        $resp->assertSeeText('The code field is required.');
+        $this->assertNull(auth()->user());
+
+        $resp = $this->post('/mfa/backup_codes/verify', [
+            'code' => 'ab123-ab456',
+        ]);
+        $resp->assertRedirect('/mfa/verify');
+
+        $resp = $this->get('/mfa/verify');
+        $resp->assertSeeText('The provided code is not valid or has already been used.');
+        $this->assertNull(auth()->user());
+    }
+
+    public function test_backup_code_verification_fails_on_attempted_code_reuse()
+    {
+        [$user, $codes, $loginResp] = $this->startBackupCodeLogin();
+
+        $this->post('/mfa/backup_codes/verify', [
+            'code' => $codes[0],
+        ]);
+        $this->assertNotNull(auth()->user());
+        auth()->logout();
+        session()->flush();
+
+        $this->post('/login', ['email' => $user->email, 'password' => 'password']);
+        $this->get('/mfa/verify');
+        $resp = $this->post('/mfa/backup_codes/verify', [
+            'code' => $codes[0],
+        ]);
+        $resp->assertRedirect('/mfa/verify');
+        $this->assertNull(auth()->user());
+
+        $resp = $this->get('/mfa/verify');
+        $resp->assertSeeText('The provided code is not valid or has already been used.');
+    }
+
+    public function test_backup_code_verification_shows_warning_when_limited_codes_remain()
+    {
+        [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']);
+
+        $resp = $this->post('/mfa/backup_codes/verify', [
+            'code' => $codes[0],
+        ]);
+        $resp = $this->followRedirects($resp);
+        $resp->assertSeeText('You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.');
+    }
+
+    public function test_both_mfa_options_available_if_set_on_profile()
+    {
+        $user = $this->getEditor();
+        $user->password = Hash::make('password');
+        $user->save();
+
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'abc123');
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '["abc12-def456"]');
+
+        /** @var TestResponse $mfaView */
+        $mfaView = $this->followingRedirects()->post('/login', [
+            'email'    => $user->email,
+            'password' => 'password',
+        ]);
+
+        // Totp shown by default
+        $mfaView->assertElementExists('form[action$="/mfa/totp/verify"] input[name="code"]');
+        $mfaView->assertElementContains('a[href$="/mfa/verify?method=backup_codes"]', 'Verify using a backup code');
+
+        // Ensure can view backup_codes view
+        $resp = $this->get('/mfa/verify?method=backup_codes');
+        $resp->assertElementExists('form[action$="/mfa/backup_codes/verify"] input[name="code"]');
+        $resp->assertElementContains('a[href$="/mfa/verify?method=totp"]', 'Verify using a mobile app');
+    }
+
+    public function test_mfa_required_with_no_methods_leads_to_setup()
+    {
+        $user = $this->getEditor();
+        $user->password = Hash::make('password');
+        $user->save();
+        /** @var Role $role */
+        $role = $user->roles->first();
+        $role->mfa_enforced = true;
+        $role->save();
+
+        $this->assertDatabaseMissing('mfa_values', [
+            'user_id' => $user->id,
+        ]);
+
+        /** @var TestResponse $resp */
+        $resp = $this->followingRedirects()->post('/login', [
+            'email'    => $user->email,
+            'password' => 'password',
+        ]);
+
+        $resp->assertSeeText('No Methods Configured');
+        $resp->assertElementContains('a[href$="/mfa/setup"]', 'Configure');
+
+        $this->get('/mfa/backup_codes/generate');
+        $resp = $this->post('/mfa/backup_codes/confirm');
+        $resp->assertRedirect('/login');
+        $this->assertDatabaseHas('mfa_values', [
+            'user_id' => $user->id,
+        ]);
+
+        $resp = $this->get('/login');
+        $resp->assertSeeText('Multi-factor method configured, Please now login again using the configured method.');
+
+        $resp = $this->followingRedirects()->post('/login', [
+            'email'    => $user->email,
+            'password' => 'password',
+        ]);
+        $resp->assertSeeText('Enter one of your remaining backup codes below:');
+    }
+
+    public function test_mfa_setup_route_access()
+    {
+        $routes = [
+            ['get', '/mfa/setup'],
+            ['get', '/mfa/totp/generate'],
+            ['post', '/mfa/totp/confirm'],
+            ['get', '/mfa/backup_codes/generate'],
+            ['post', '/mfa/backup_codes/confirm'],
+        ];
+
+        // Non-auth access
+        foreach ($routes as [$method, $path]) {
+            $resp = $this->call($method, $path);
+            $resp->assertRedirect('/login');
+        }
+
+        // Attempted login user, who has configured mfa, access
+        // Sets up user that has MFA required after attempted login.
+        $loginService = $this->app->make(LoginService::class);
+        $user = $this->getEditor();
+        /** @var Role $role */
+        $role = $user->roles->first();
+        $role->mfa_enforced = true;
+        $role->save();
+
+        try {
+            $loginService->login($user, 'testing');
+        } catch (StoppedAuthenticationException $e) {
+        }
+        $this->assertNotNull($loginService->getLastLoginAttemptUser());
+
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, '[]');
+        foreach ($routes as [$method, $path]) {
+            $resp = $this->call($method, $path);
+            $resp->assertRedirect('/login');
+        }
+    }
+
+    /**
+     * @return array<User, string, TestResponse>
+     */
+    protected function startTotpLogin(): array
+    {
+        $secret = $this->app->make(TotpService::class)->generateSecret();
+        $user = $this->getEditor();
+        $user->password = Hash::make('password');
+        $user->save();
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, $secret);
+        $loginResp = $this->post('/login', [
+            'email'    => $user->email,
+            'password' => 'password',
+        ]);
+
+        return [$user, $secret, $loginResp];
+    }
+
+    /**
+     * @return array<User, string, TestResponse>
+     */
+    protected function startBackupCodeLogin($codes = ['kzzu6-1pgll', 'bzxnf-plygd', 'bwdsp-ysl51', '1vo93-ioy7n', 'lf7nw-wdyka', 'xmtrd-oplac']): array
+    {
+        $user = $this->getEditor();
+        $user->password = Hash::make('password');
+        $user->save();
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
+        $loginResp = $this->post('/login', [
+            'email'    => $user->email,
+            'password' => 'password',
+        ]);
+
+        return [$user, $codes, $loginResp];
+    }
+}
diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php
new file mode 100644 (file)
index 0000000..9fa4d00
--- /dev/null
@@ -0,0 +1,406 @@
+<?php
+
+namespace Tests\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\User;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\Psr7\Response;
+use Illuminate\Filesystem\Cache;
+use Tests\Helpers\OidcJwtHelper;
+use Tests\TestCase;
+use Tests\TestResponse;
+
+class OidcTest extends TestCase
+{
+    protected $keyFilePath;
+    protected $keyFile;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        // Set default config for OpenID Connect
+
+        $this->keyFile = tmpfile();
+        $this->keyFilePath = 'file://' . stream_get_meta_data($this->keyFile)['uri'];
+        file_put_contents($this->keyFilePath, OidcJwtHelper::publicPemKey());
+
+        config()->set([
+            'auth.method'                 => 'oidc',
+            'auth.defaults.guard'         => 'oidc',
+            'oidc.name'                   => 'SingleSignOn-Testing',
+            'oidc.display_name_claims'    => ['name'],
+            'oidc.client_id'              => OidcJwtHelper::defaultClientId(),
+            'oidc.client_secret'          => 'testpass',
+            'oidc.jwt_public_key'         => $this->keyFilePath,
+            'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
+            'oidc.authorization_endpoint' => 'https://oidc.local/auth',
+            'oidc.token_endpoint'         => 'https://oidc.local/token',
+            'oidc.discover'               => false,
+            'oidc.dump_user_details'      => false,
+        ]);
+    }
+
+    protected function tearDown(): void
+    {
+        parent::tearDown();
+        if (file_exists($this->keyFilePath)) {
+            unlink($this->keyFilePath);
+        }
+    }
+
+    public function test_login_option_shows_on_login_page()
+    {
+        $req = $this->get('/login');
+        $req->assertSeeText('SingleSignOn-Testing');
+        $req->assertElementExists('form[action$="/oidc/login"][method=POST] button');
+    }
+
+    public function test_oidc_routes_are_only_active_if_oidc_enabled()
+    {
+        config()->set(['auth.method' => 'standard']);
+        $routes = ['/login' => 'post', '/callback' => 'get'];
+        foreach ($routes as $uri => $method) {
+            $req = $this->call($method, '/oidc' . $uri);
+            $this->assertPermissionError($req);
+        }
+    }
+
+    public function test_forgot_password_routes_inaccessible()
+    {
+        $resp = $this->get('/password/email');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/password/email');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->get('/password/reset/abc123');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/password/reset');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_standard_login_routes_inaccessible()
+    {
+        $resp = $this->post('/login');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_logout_route_functions()
+    {
+        $this->actingAs($this->getEditor());
+        $this->post('/logout');
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_user_invite_routes_inaccessible()
+    {
+        $resp = $this->get('/register/invite/abc123');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/register/invite/abc123');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_user_register_routes_inaccessible()
+    {
+        $resp = $this->get('/register');
+        $this->assertPermissionError($resp);
+
+        $resp = $this->post('/register');
+        $this->assertPermissionError($resp);
+    }
+
+    public function test_login()
+    {
+        $req = $this->post('/oidc/login');
+        $redirect = $req->headers->get('location');
+
+        $this->assertStringStartsWith('https://oidc.local/auth', $redirect, 'Login redirects to SSO location');
+        $this->assertFalse($this->isAuthenticated());
+        $this->assertStringContainsString('scope=openid%20profile%20email', $redirect);
+        $this->assertStringContainsString('client_id=' . OidcJwtHelper::defaultClientId(), $redirect);
+        $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $redirect);
+    }
+
+    public function test_login_success_flow()
+    {
+        // Start auth
+        $this->post('/oidc/login');
+        $state = session()->get('oidc_state');
+
+        $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
+            'email' => '[email protected]',
+            'sub'   => 'benny1010101',
+        ])]);
+
+        // Callback from auth provider
+        // App calls token endpoint to get id token
+        $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
+        $resp->assertRedirect('/');
+        $this->assertCount(1, $transactions);
+        /** @var Request $tokenRequest */
+        $tokenRequest = $transactions[0]['request'];
+        $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
+        $this->assertEquals('POST', $tokenRequest->getMethod());
+        $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
+        $this->assertStringContainsString('grant_type=authorization_code', $tokenRequest->getBody());
+        $this->assertStringContainsString('code=SplxlOBeZQQYbYS6WxSbIA', $tokenRequest->getBody());
+        $this->assertStringContainsString('redirect_uri=' . urlencode(url('/oidc/callback')), $tokenRequest->getBody());
+
+        $this->assertTrue(auth()->check());
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'external_auth_id' => 'benny1010101',
+            'email_confirmed'  => false,
+        ]);
+
+        $user = User::query()->where('email', '=', '[email protected]')->first();
+        $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, "oidc; ({$user->id}) Barry Scott");
+    }
+
+    public function test_callback_fails_if_no_state_present_or_matching()
+    {
+        $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
+        $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+
+        $this->post('/oidc/login');
+        $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=abc124');
+        $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);
+
+        $resp = $this->runLogin([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+        ]);
+
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+            'iss'   => OidcJwtHelper::defaultIssuer(),
+            'aud'   => OidcJwtHelper::defaultClientId(),
+        ]);
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_auth_fails_if_no_email_exists_in_user_data()
+    {
+        $this->runLogin([
+            'email' => '',
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertSessionError('Could not find an email address, for this user, in the data provided by the external authentication system');
+    }
+
+    public function test_auth_fails_if_already_logged_in()
+    {
+        $this->asEditor();
+
+        $this->runLogin([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertSessionError('Already logged in');
+    }
+
+    public function test_auth_login_as_existing_user()
+    {
+        $editor = $this->getEditor();
+        $editor->external_auth_id = 'benny505';
+        $editor->save();
+
+        $this->assertFalse(auth()->check());
+
+        $this->runLogin([
+            'email' => '[email protected]',
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertTrue(auth()->check());
+        $this->assertEquals($editor->id, auth()->user()->id);
+    }
+
+    public function test_auth_login_as_existing_user_email_with_different_auth_id_fails()
+    {
+        $editor = $this->getEditor();
+        $editor->external_auth_id = 'editor101';
+        $editor->save();
+
+        $this->assertFalse(auth()->check());
+
+        $this->runLogin([
+            'email' => $editor->email,
+            'sub'   => 'benny505',
+        ]);
+
+        $this->assertSessionError('A user with the email ' . $editor->email . ' already exists but with different credentials.');
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_auth_login_with_invalid_token_fails()
+    {
+        $this->runLogin([
+            'sub' => null,
+        ]);
+
+        $this->assertSessionError('ID token validate failed with error: Missing token subject value');
+        $this->assertFalse(auth()->check());
+    }
+
+    public function test_auth_login_with_autodiscovery()
+    {
+        $this->withAutodiscovery();
+
+        $transactions = &$this->mockHttpClient([
+            $this->getAutoDiscoveryResponse(),
+            $this->getJwksResponse(),
+        ]);
+
+        $this->assertFalse(auth()->check());
+
+        $this->runLogin();
+
+        $this->assertTrue(auth()->check());
+        /** @var Request $discoverRequest */
+        $discoverRequest = $transactions[0]['request'];
+        /** @var Request $discoverRequest */
+        $keysRequest = $transactions[1]['request'];
+
+        $this->assertEquals('GET', $keysRequest->getMethod());
+        $this->assertEquals('GET', $discoverRequest->getMethod());
+        $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
+        $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/oidc/keys', $keysRequest->getUri());
+    }
+
+    public function test_auth_fails_if_autodiscovery_fails()
+    {
+        $this->withAutodiscovery();
+        $this->mockHttpClient([
+            new Response(404, [], 'Not found'),
+        ]);
+
+        $this->runLogin();
+        $this->assertFalse(auth()->check());
+        $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+    }
+
+    public function test_autodiscovery_calls_are_cached()
+    {
+        $this->withAutodiscovery();
+
+        $transactions = &$this->mockHttpClient([
+            $this->getAutoDiscoveryResponse(),
+            $this->getJwksResponse(),
+            $this->getAutoDiscoveryResponse([
+                'issuer' => 'https://auto.example.com',
+            ]),
+            $this->getJwksResponse(),
+        ]);
+
+        // Initial run
+        $this->post('/oidc/login');
+        $this->assertCount(2, $transactions);
+        // Second run, hits cache
+        $this->post('/oidc/login');
+        $this->assertCount(2, $transactions);
+
+        // Third run, different issuer, new cache key
+        config()->set(['oidc.issuer' => 'https://auto.example.com']);
+        $this->post('/oidc/login');
+        $this->assertCount(4, $transactions);
+    }
+
+    public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
+    {
+        $this->withAutodiscovery();
+
+        $keyArray = OidcJwtHelper::publicJwkKeyArray();
+        unset($keyArray['alg']);
+
+        $this->mockHttpClient([
+            $this->getAutoDiscoveryResponse(),
+            new Response(200, [
+                'Content-Type'  => 'application/json',
+                'Cache-Control' => 'no-cache, no-store',
+                'Pragma'        => 'no-cache',
+            ], json_encode([
+                'keys' => [
+                    $keyArray,
+                ],
+            ])),
+        ]);
+
+        $this->assertFalse(auth()->check());
+        $this->runLogin();
+        $this->assertTrue(auth()->check());
+    }
+
+    protected function withAutodiscovery()
+    {
+        config()->set([
+            'oidc.issuer'                 => OidcJwtHelper::defaultIssuer(),
+            'oidc.discover'               => true,
+            'oidc.authorization_endpoint' => null,
+            'oidc.token_endpoint'         => null,
+            'oidc.jwt_public_key'         => null,
+        ]);
+    }
+
+    protected function runLogin($claimOverrides = []): TestResponse
+    {
+        $this->post('/oidc/login');
+        $state = session()->get('oidc_state');
+        $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]);
+
+        return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
+    }
+
+    protected function getAutoDiscoveryResponse($responseOverrides = []): Response
+    {
+        return new Response(200, [
+            'Content-Type'  => 'application/json',
+            'Cache-Control' => 'no-cache, no-store',
+            'Pragma'        => 'no-cache',
+        ], json_encode(array_merge([
+            'token_endpoint'         => OidcJwtHelper::defaultIssuer() . '/oidc/token',
+            'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
+            'jwks_uri'               => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
+            'issuer'                 => OidcJwtHelper::defaultIssuer(),
+        ], $responseOverrides)));
+    }
+
+    protected function getJwksResponse(): Response
+    {
+        return new Response(200, [
+            'Content-Type'  => 'application/json',
+            'Cache-Control' => 'no-cache, no-store',
+            'Pragma'        => 'no-cache',
+        ], json_encode([
+            'keys' => [
+                OidcJwtHelper::publicJwkKeyArray(),
+            ],
+        ]));
+    }
+
+    protected function getMockAuthorizationResponse($claimOverrides = []): Response
+    {
+        return new Response(200, [
+            'Content-Type'  => 'application/json',
+            'Cache-Control' => 'no-cache, no-store',
+            'Pragma'        => 'no-cache',
+        ], json_encode([
+            'access_token' => 'abc123',
+            'token_type'   => 'Bearer',
+            'expires_in'   => 3600,
+            'id_token'     => OidcJwtHelper::idToken($claimOverrides),
+        ]));
+    }
+}
index 58c02b47188a625b83a5aeb3d0ad80c5f4f81de0..cb217585c5fc18f7018aa9a83a0bbdf4b984a361 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Auth;
+<?php
+
+namespace Tests\Auth;
 
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -6,28 +8,28 @@ use Tests\TestCase;
 
 class Saml2Test extends TestCase
 {
-
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
         // Set default config for SAML2
         config()->set([
-            'auth.method' => 'saml2',
-            'auth.defaults.guard' => 'saml2',
-            'saml2.name' => 'SingleSignOn-Testing',
-            'saml2.email_attribute' => 'email',
-            'saml2.display_name_attributes' => ['first_name', 'last_name'],
-            'saml2.external_id_attribute' => 'uid',
-            'saml2.user_to_groups' => false,
-            'saml2.group_attribute' => 'user_groups',
-            'saml2.remove_from_groups' => false,
-            'saml2.onelogin_overrides' => null,
-            'saml2.onelogin.idp.entityId' => 'http://saml.local/saml2/idp/metadata.php',
-            'saml2.onelogin.idp.singleSignOnService.url' => 'http://saml.local/saml2/idp/SSOService.php',
-            'saml2.onelogin.idp.singleLogoutService.url' => 'http://saml.local/saml2/idp/SingleLogoutService.php',
-            'saml2.autoload_from_metadata' => false,
-            'saml2.onelogin.idp.x509cert' => $this->testCert,
-            'saml2.onelogin.debug' => false,
+            'auth.method'                                   => 'saml2',
+            'auth.defaults.guard'                           => 'saml2',
+            'saml2.name'                                    => 'SingleSignOn-Testing',
+            'saml2.email_attribute'                         => 'email',
+            'saml2.display_name_attributes'                 => ['first_name', 'last_name'],
+            'saml2.external_id_attribute'                   => 'uid',
+            'saml2.user_to_groups'                          => false,
+            'saml2.group_attribute'                         => 'user_groups',
+            'saml2.remove_from_groups'                      => false,
+            'saml2.onelogin_overrides'                      => null,
+            'saml2.onelogin.idp.entityId'                   => 'http://saml.local/saml2/idp/metadata.php',
+            'saml2.onelogin.idp.singleSignOnService.url'    => 'http://saml.local/saml2/idp/SSOService.php',
+            'saml2.onelogin.idp.singleLogoutService.url'    => 'http://saml.local/saml2/idp/SingleLogoutService.php',
+            'saml2.autoload_from_metadata'                  => false,
+            'saml2.onelogin.idp.x509cert'                   => $this->testCert,
+            'saml2.onelogin.debug'                          => false,
+            'saml2.onelogin.security.requestedAuthnContext' => true,
         ]);
     }
 
@@ -47,7 +49,7 @@ class Saml2Test extends TestCase
         $req = $this->get('/saml2/metadata');
         $req->assertSee('https://example.com/super-cats');
         $req->assertSee('md:ContactPerson');
-        $req->assertSee('<md:GivenName>Barry Scott</md:GivenName>');
+        $req->assertSee('<md:GivenName>Barry Scott</md:GivenName>', false);
     }
 
     public function test_login_option_shows_on_login_page()
@@ -66,62 +68,86 @@ class Saml2Test extends TestCase
         config()->set(['saml2.onelogin.strict' => false]);
         $this->assertFalse($this->isAuthenticated());
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
+        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $redirect = $acsPost->headers->get('Location');
+        $acsId = explode('?id=', $redirect)[1];
+        $this->assertTrue(strlen($acsId) > 12);
 
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/');
-            $this->assertTrue($this->isAuthenticated());
-            $this->assertDatabaseHas('users', [
-                'email' => '[email protected]',
-                'external_auth_id' => 'user',
-                'email_confirmed' => false,
-                'name' => 'Barry Scott'
-            ]);
-
-        });
+        $this->assertStringContainsString('/saml2/acs?id=', $redirect);
+        $this->assertTrue(cache()->has('saml2_acs:' . $acsId));
+
+        $acsGet = $this->get($redirect);
+        $acsGet->assertRedirect('/');
+        $this->assertFalse(cache()->has('saml2_acs:' . $acsId));
+
+        $this->assertTrue($this->isAuthenticated());
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'external_auth_id' => 'user',
+            'email_confirmed'  => false,
+            'name'             => 'Barry Scott',
+        ]);
+    }
+
+    public function test_acs_process_id_randomly_generated()
+    {
+        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $redirectA = $acsPost->headers->get('Location');
+
+        $acsPost = $this->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $redirectB = $acsPost->headers->get('Location');
+
+        $this->assertFalse($redirectA === $redirectB);
+    }
+
+    public function test_process_acs_endpoint_cant_be_called_with_invalid_id()
+    {
+        $resp = $this->get('/saml2/acs');
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
+
+        $resp = $this->get('/saml2/acs?id=abc123');
+        $resp->assertRedirect('/login');
+        $this->followRedirects($resp)->assertSeeText('Login using SingleSignOn-Testing failed, system did not provide successful authorization');
     }
 
     public function test_group_role_sync_on_login()
     {
         config()->set([
-            'saml2.onelogin.strict' => false,
-            'saml2.user_to_groups' => true,
+            'saml2.onelogin.strict'    => false,
+            'saml2.user_to_groups'     => true,
             'saml2.remove_from_groups' => false,
         ]);
 
-        $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
+        $memberRole = Role::factory()->create(['external_auth_id' => 'member']);
         $adminRole = Role::getSystemRole('admin');
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
-            $acsPost = $this->post('/saml2/acs');
-            $user = User::query()->where('external_auth_id', '=', 'user')->first();
+        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $user = User::query()->where('external_auth_id', '=', 'user')->first();
 
-            $userRoleIds = $user->roles()->pluck('id');
-            $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
-            $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
-        });
+        $userRoleIds = $user->roles()->pluck('id');
+        $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
+        $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
     }
 
     public function test_group_role_sync_removal_option_works_as_expected()
     {
         config()->set([
-            'saml2.onelogin.strict' => false,
-            'saml2.user_to_groups' => true,
+            'saml2.onelogin.strict'    => false,
+            'saml2.user_to_groups'     => true,
             'saml2.remove_from_groups' => true,
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $user = User::query()->where('external_auth_id', '=', 'user')->first();
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $user = User::query()->where('external_auth_id', '=', 'user')->first();
 
-            $randomRole = factory(Role::class)->create(['external_auth_id' => 'random']);
-            $user->attachRole($randomRole);
-            $this->assertContains($randomRole->id, $user->roles()->pluck('id'));
+        $randomRole = Role::factory()->create(['external_auth_id' => 'random']);
+        $user->attachRole($randomRole);
+        $this->assertContains($randomRole->id, $user->roles()->pluck('id'));
 
-            auth()->logout();
-            $acsPost = $this->post('/saml2/acs');
-            $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
-        });
+        auth()->logout();
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $this->assertNotContains($randomRole->id, $user->roles()->pluck('id'));
     }
 
     public function test_logout_link_directs_to_saml_path()
@@ -131,8 +157,7 @@ class Saml2Test extends TestCase
         ]);
 
         $resp = $this->actingAs($this->getEditor())->get('/');
-        $resp->assertElementExists('a[href$="/saml2/logout"]');
-        $resp->assertElementContains('a[href$="/saml2/logout"]', 'Logout');
+        $resp->assertElementContains('form[action$="/saml2/logout"] button', 'Logout');
     }
 
     public function test_logout_sls_flow()
@@ -149,63 +174,54 @@ class Saml2Test extends TestCase
             $this->assertFalse($this->isAuthenticated());
         };
 
-        $loginAndStartLogout = function () use ($handleLogoutResponse) {
-            $this->post('/saml2/acs');
-
-            $req = $this->get('/saml2/logout');
-            $redirect = $req->headers->get('location');
-            $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
-            $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
-        };
+        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
+        $req = $this->post('/saml2/logout');
+        $redirect = $req->headers->get('location');
+        $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect);
+        $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse);
     }
 
     public function test_logout_sls_flow_when_sls_not_configured()
     {
         config()->set([
-            'saml2.onelogin.strict' => false,
+            'saml2.onelogin.strict'                      => false,
             'saml2.onelogin.idp.singleLogoutService.url' => null,
         ]);
 
-        $loginAndStartLogout = function () {
-            $this->post('/saml2/acs');
-
-            $req = $this->get('/saml2/logout');
-            $req->assertRedirect('/');
-            $this->assertFalse($this->isAuthenticated());
-        };
+        $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $this->assertTrue($this->isAuthenticated());
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], $loginAndStartLogout);
+        $req = $this->post('/saml2/logout');
+        $req->assertRedirect('/');
+        $this->assertFalse($this->isAuthenticated());
     }
 
     public function test_dump_user_details_option_works()
     {
         config()->set([
-            'saml2.onelogin.strict' => false,
+            'saml2.onelogin.strict'   => false,
             'saml2.dump_user_details' => true,
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertJsonStructure([
-                'id_from_idp',
-                'attrs_from_idp' => [],
-                'attrs_after_parsing' => [],
-            ]);
-        });
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $acsPost->assertJsonStructure([
+            'id_from_idp',
+            'attrs_from_idp'      => [],
+            'attrs_after_parsing' => [],
+        ]);
     }
 
     public function test_saml_routes_are_only_active_if_saml_enabled()
     {
         config()->set(['auth.method' => 'standard']);
-        $getRoutes = ['/logout', '/metadata', '/sls'];
+        $getRoutes = ['/metadata', '/sls'];
         foreach ($getRoutes as $route) {
             $req = $this->get('/saml2' . $route);
             $this->assertPermissionError($req);
         }
 
-        $postRoutes = ['/login', '/acs'];
+        $postRoutes = ['/login', '/acs', '/logout'];
         foreach ($postRoutes as $route) {
             $req = $this->post('/saml2' . $route);
             $this->assertPermissionError($req);
@@ -232,7 +248,7 @@ class Saml2Test extends TestCase
         $resp = $this->post('/login');
         $this->assertPermissionError($resp);
 
-        $resp = $this->get('/logout');
+        $resp = $this->post('/logout');
         $this->assertPermissionError($resp);
     }
 
@@ -257,48 +273,45 @@ class Saml2Test extends TestCase
     public function test_email_domain_restriction_active_on_new_saml_login()
     {
         $this->setSettings([
-            'registration-restrict' => 'testing.com'
+            'registration-restrict' => 'testing.com',
         ]);
         config()->set([
             'saml2.onelogin.strict' => false,
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/login');
-            $errorMessage = session()->get('error');
-            $this->assertStringContainsString('That email domain does not have access to this application', $errorMessage);
-            $this->assertDatabaseMissing('users', ['email' => '[email protected]']);
-        });
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $acsPost->assertSeeText('That email domain does not have access to this application');
+        $this->assertFalse(auth()->check());
+        $this->assertDatabaseMissing('users', ['email' => '[email protected]']);
     }
 
     public function test_group_sync_functions_when_email_confirmation_required()
     {
         setting()->put('registration-confirmation', 'true');
         config()->set([
-            'saml2.onelogin.strict' => false,
-            'saml2.user_to_groups' => true,
+            'saml2.onelogin.strict'    => false,
+            'saml2.user_to_groups'     => true,
             'saml2.remove_from_groups' => false,
         ]);
 
-        $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
+        $memberRole = Role::factory()->create(['external_auth_id' => 'member']);
         $adminRole = Role::getSystemRole('admin');
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
-            $acsPost = $this->followingRedirects()->post('/saml2/acs');
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
 
-            $this->assertEquals('http://localhost/register/confirm', url()->current());
-            $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
-            $user = User::query()->where('external_auth_id', '=', 'user')->first();
+        $this->assertEquals('http://localhost/register/confirm', url()->current());
+        $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.');
+        /** @var User $user */
+        $user = User::query()->where('external_auth_id', '=', 'user')->first();
 
-            $userRoleIds = $user->roles()->pluck('id');
-            $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
-            $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
-            $this->assertTrue($user->email_confirmed == false, 'User email remains unconfirmed');
-        });
+        $userRoleIds = $user->roles()->pluck('id');
+        $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role');
+        $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role');
+        $this->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');
 
+        $this->assertNull(auth()->user());
         $homeGet = $this->get('/');
-        $homeGet->assertRedirect('/register/confirm/awaiting');
+        $homeGet->assertRedirect('/login');
     }
 
     public function test_login_where_existing_non_saml_user_shows_warning()
@@ -308,34 +321,60 @@ class Saml2Test extends TestCase
 
         // Make the user pre-existing in DB with different auth_id
         User::query()->forceCreate([
-            'email' => '[email protected]',
+            'email'            => '[email protected]',
             'external_auth_id' => 'old_system_user_id',
-            'email_confirmed' => false,
-            'name' => 'Barry Scott'
+            'email_confirmed'  => false,
+            'name'             => 'Barry Scott',
         ]);
 
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/login');
-            $this->assertFalse($this->isAuthenticated());
-            $this->assertDatabaseHas('users', [
-                'email' => '[email protected]',
-                'external_auth_id' => 'old_system_user_id',
-            ]);
-
-            $loginGet = $this->get('/login');
-            $loginGet->assertSee("A user with the email [email protected] already exists but with different credentials");
-        });
+        $acsPost = $this->followingRedirects()->post('/saml2/acs', ['SAMLResponse' => $this->acsPostData]);
+        $this->assertFalse($this->isAuthenticated());
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'external_auth_id' => 'old_system_user_id',
+        ]);
+
+        $acsPost->assertSee('A user with the email [email protected] already exists but with different credentials');
     }
 
-    protected function withGet(array $options, callable $callback)
+    public function test_login_request_contains_expected_default_authncontext()
     {
-        return $this->withGlobal($_GET, $options, $callback);
+        $authReq = $this->getAuthnRequest();
+        $this->assertStringContainsString('samlp:RequestedAuthnContext Comparison="exact"', $authReq);
+        $this->assertStringContainsString('<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>', $authReq);
     }
 
-    protected function withPost(array $options, callable $callback)
+    public function test_false_idp_authncontext_option_does_not_pass_authncontext_in_saml_request()
     {
-        return $this->withGlobal($_POST, $options, $callback);
+        config()->set(['saml2.onelogin.security.requestedAuthnContext' => false]);
+        $authReq = $this->getAuthnRequest();
+        $this->assertStringNotContainsString('samlp:RequestedAuthnContext', $authReq);
+        $this->assertStringNotContainsString('<saml:AuthnContextClassRef>', $authReq);
+    }
+
+    public function test_array_idp_authncontext_option_passes_value_as_authncontextclassref_in_request()
+    {
+        config()->set(['saml2.onelogin.security.requestedAuthnContext' => ['urn:federation:authentication:windows', 'urn:federation:authentication:linux']]);
+        $authReq = $this->getAuthnRequest();
+        $this->assertStringContainsString('samlp:RequestedAuthnContext', $authReq);
+        $this->assertStringContainsString('<saml:AuthnContextClassRef>urn:federation:authentication:windows</saml:AuthnContextClassRef>', $authReq);
+        $this->assertStringContainsString('<saml:AuthnContextClassRef>urn:federation:authentication:linux</saml:AuthnContextClassRef>', $authReq);
+    }
+
+    protected function getAuthnRequest(): string
+    {
+        $req = $this->post('/saml2/login');
+        $location = $req->headers->get('Location');
+        $query = explode('?', $location)[1];
+        $params = [];
+        parse_str($query, $params);
+
+        return gzinflate(base64_decode($params['SAMLRequest']));
+    }
+
+    protected function withGet(array $options, callable $callback)
+    {
+        return $this->withGlobal($_GET, $options, $callback);
     }
 
     protected function withGlobal(array &$global, array $options, callable $callback)
@@ -362,23 +401,23 @@ class Saml2Test extends TestCase
      * The post data for a callback for single-sign-in.
      * Provides the following attributes:
      * array:5 [
-        "uid" => array:1 [
-            0 => "user"
-        ]
-        "first_name" => array:1 [
-            0 => "Barry"
-        ]
-        "last_name" => array:1 [
-            0 => "Scott"
-        ]
-        "email" => array:1 [
-            0 => "[email protected]"
-        ]
-        "user_groups" => array:2 [
-            0 => "member"
-            1 => "admin"
-        ]
-    ]
+     * "uid" => array:1 [
+     * 0 => "user"
+     * ]
+     * "first_name" => array:1 [
+     * 0 => "Barry"
+     * ]
+     * "last_name" => array:1 [
+     * 0 => "Scott"
+     * ]
+     * "email" => array:1 [
+     * 0 => "[email protected]"
+     * ]
+     * "user_groups" => array:2 [
+     * 0 => "member"
+     * 1 => "admin"
+     * ]
+     * ].
      */
     protected $acsPostData = 'PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIElEPSJfNGRkNDU2NGRjNzk0MDYxZWYxYmFhMDQ2N2Q3OTAyOGNlZDNjZTU0YmVlIiBWZXJzaW9uPSIyLjAiIElzc3VlSW5zdGFudD0iMjAxOS0xMS0xN1QxNzo1MzozOVoiIERlc3RpbmF0aW9uPSJodHRwOi8vYm9va3N0YWNrLmxvY2FsL3NhbWwyL2FjcyIgSW5SZXNwb25zZVRvPSJPTkVMT0dJTl82YTBmNGYzOTkzMDQwZjE5ODdmZDM3MDY4YjUyOTYyMjlhZDUzNjFjIj48c2FtbDpJc3N1ZXI+aHR0cDovL3NhbWwubG9jYWwvc2FtbDIvaWRwL21ldGFkYXRhLnBocDwvc2FtbDpJc3N1ZXI+PGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+CiAgPGRzOlNpZ25lZEluZm8+PGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KICAgIDxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+CiAgPGRzOlJlZmVyZW5jZSBVUkk9IiNfNGRkNDU2NGRjNzk0MDYxZWYxYmFhMDQ2N2Q3OTAyOGNlZDNjZTU0YmVlIj48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNzaGEyNTYiLz48ZHM6RGlnZXN0VmFsdWU+dm1oL1M3NU5mK2crZWNESkN6QWJaV0tKVmx1ZzdCZnNDKzlhV05lSXJlUT08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+dnJhZ0tKWHNjVm5UNjJFaEk3bGk4MERUWHNOTGJOc3lwNWZ2QnU4WjFYSEtFUVA3QWpPNkcxcVBwaGpWQ2dRMzd6TldVVTZvUytQeFA3UDlHeG5xL3hKejRUT3lHcHJ5N1RoK2pIcHc0YWVzQTdrTmp6VU51UmU2c1ltWTlrRXh2VjMvTmJRZjROMlM2Y2RhRHIzWFRodllVVDcxYzQwNVVHOFJpQjJaY3liWHIxZU1yWCtXUDBnU2Qrc0F2RExqTjBJc3pVWlVUNThadFpEVE1ya1ZGL0pIbFBFQ04vVW1sYVBBeitTcUJ4c25xTndZK1oxYUt3MnlqeFRlNnUxM09Kb29OOVN1REowNE0rK2F3RlY3NkI4cXEyTzMxa3FBbDJibm1wTGxtTWdRNFEraUlnL3dCc09abTV1clphOWJObDNLVEhtTVBXbFpkbWhsLzgvMy9IT1RxN2thWGs3cnlWRHRLcFlsZ3FUajNhRUpuL0dwM2o4SFp5MUVialRiOTRRT1ZQMG5IQzB1V2hCaE13TjdzVjFrUSsxU2NjUlpUZXJKSGlSVUQvR0srTVg3M0YrbzJVTFRIL1Z6Tm9SM2o4N2hOLzZ1UC9JeG5aM1RudGR1MFZPZS9ucEdVWjBSMG9SWFhwa2JTL2poNWk1ZjU0RXN4eXZ1VEM5NHdKaEM8L2RzOlNpZ25hdHVyZVZhbHVlPgo8ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlFYXpDQ0F0T2dBd0lCQWdJVWU3YTA4OENucjRpem1ybkJFbng1cTNIVE12WXdEUVlKS29aSWh2Y05BUUVMQlFBd1JURUxNQWtHQTFVRUJoTUNSMEl4RXpBUkJnTlZCQWdNQ2xOdmJXVXRVM1JoZEdVeElUQWZCZ05WQkFvTUdFbHVkR1Z5Ym1WMElGZHBaR2RwZEhNZ1VIUjVJRXgwWkRBZUZ3MHhPVEV4TVRZeE1qRTNNVFZhRncweU9URXhNVFV4TWpFM01UVmFNRVV4Q3pBSkJnTlZCQVlUQWtkQ01STXdFUVlEVlFRSURBcFRiMjFsTFZOMFlYUmxNU0V3SHdZRFZRUUtEQmhKYm5SbGNtNWxkQ0JYYVdSbmFYUnpJRkIwZVNCTWRHUXdnZ0dpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCandBd2dnR0tBb0lCZ1FEekxlOUZmZHlwbFR4SHA0U3VROWdRdFpUM3QrU0RmdkVMNzJwcENmRlp3NytCNXM1Qi9UNzNhWHBvUTNTNTNwR0kxUklXQ2dlMmlDVVEydHptMjdhU05IMGl1OWFKWWNVUVovUklUcWQwYXl5RGtzMU5BMlBUM1RXNnQzbTdLVjVyZTRQME5iK1lEZXV5SGRreitqY010cG44Q21Cb1QwSCtza2hhMGhpcUlOa2prUlBpSHZMSFZHcCt0SFVFQS9JNm1ONGFCL1VFeFNUTHM3OU5zTFVmdGVxcXhlOSt0dmRVYVRveURQcmhQRmpPTnMrOU5LQ2t6SUM2dmN2N0o2QXR1S0c2bkVUK3pCOXlPV2d0R1lRaWZYcVFBMnk1ZEw4MUJCMHE1dU1hQkxTMnBxM2FQUGp6VTJGMytFeXNqeVNXVG5Da2ZrN0M1U3NDWFJ1OFErVTk1dHVucE5md2Y1b2xFNldhczQ4Tk1NK1B3VjdpQ05NUGtOemxscTZQQ2lNK1A4RHJNU2N6elVaWlFVU3Y2ZFN3UENvK1lTVmltRU0wT2czWEpUaU5oUTVBTmxhSW42Nkt3NWdmb0JmdWlYbXlJS2lTRHlBaURZbUZhZjQzOTV3V3dMa1RSK2N3OFdmamFIc3dLWlRvbW4xTVIzT0pzWTJVSjBlUkJZTStZU3NDQXdFQUFhTlRNRkV3SFFZRFZSME9CQllFRkltcDJDWUNHZmNiN3c5MUgvY1NoVENrWHdSL01COEdBMVVkSXdRWU1CYUFGSW1wMkNZQ0dmY2I3dzkxSC9jU2hUQ2tYd1IvTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3RFFZSktvWklodmNOQVFFTEJRQURnZ0dCQUErZy9DN3VMOWxuK1crcUJrbkxXODFrb2pZZmxnUEsxSTFNSEl3bk12bC9aVEhYNGRSWEtEcms3S2NVcTFLanFhak5WNjZmMWNha3AwM0lpakJpTzBYaTFnWFVaWUxvQ2lOR1V5eXA5WGxvaUl5OVh3MlBpV25ydzAreVp5dlZzc2JlaFhYWUpsNFJpaEJqQld1bDlSNHdNWUxPVVNKRGUyV3hjVUJoSm54eU5ScytQMHhMU1FYNkIybjZueG9Ea280cDA3czhaS1hRa2VpWjJpd0ZkVHh6UmtHanRoTVV2NzA0bnpzVkdCVDBEQ1B0ZlNhTzVLSlpXMXJDczN5aU10aG5CeHE0cUVET1FKRklsKy9MRDcxS2JCOXZaY1c1SnVhdnpCRm1rS0dOcm8vNkcxSTdlbDQ2SVI0d2lqVHlORkNZVXVEOWR0aWduTm1wV3ROOE9XK3B0aUwvanRUeVNXdWtqeXMwcyt2TG44M0NWdmpCMGRKdFZBSVlPZ1hGZEl1aWk2Nmdjend3TS9MR2lPRXhKbjBkVE56c0ovSVlocHhMNEZCRXVQMHBza1kwbzBhVWxKMkxTMmord1NRVFJLc0JnTWp5clVyZWtsZTJPRFN0U3RuM2VhYmpJeDAvRkhscEZyMGpOSW0vb01QN2t3anRVWDR6YU5lNDdRSTRHZz09PC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWxwOlN0YXR1cz48c2FtbHA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1scDpTdGF0dXM+PHNhbWw6QXNzZXJ0aW9uIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgSUQ9Il82ODQyZGY5YzY1OWYxM2ZlNTE5NmNkOWVmNmMyZjAyODM2NGFlOTQzYjEiIFZlcnNpb249IjIuMCIgSXNzdWVJbnN0YW50PSIyMDE5LTExLTE3VDE3OjUzOjM5WiI+PHNhbWw6SXNzdWVyPmh0dHA6Ly9zYW1sLmxvY2FsL3NhbWwyL2lkcC9tZXRhZGF0YS5waHA8L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgogIDxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICA8ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxkc2lnLW1vcmUjcnNhLXNoYTI1NiIvPgogIDxkczpSZWZlcmVuY2UgVVJJPSIjXzY4NDJkZjljNjU5ZjEzZmU1MTk2Y2Q5ZWY2YzJmMDI4MzY0YWU5NDNiMSI+PGRzOlRyYW5zZm9ybXM+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHM6VHJhbnNmb3Jtcz48ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2Ii8+PGRzOkRpZ2VzdFZhbHVlPmtyYjV3NlM4dG9YYy9lU3daUFVPQnZRem4zb3M0SkFDdXh4ckpreHBnRnc9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPjJxcW1Ba3hucXhOa3N5eXh5dnFTVDUxTDg5VS9ZdHpja2t1ekF4ci9hQ1JTK1NPRzg1YkFNWm8vU3puc3d0TVlBYlFRQ0VGb0R1amdNdlpzSFl3NlR2dmFHanlXWUpRNVZyYWhlemZaSWlCVUU0NHBtWGFrOCswV0l0WTVndnBGSXhxWFZaRmdFUkt2VExmZVFCMzhkMVZQc0ZVZ0RYdXQ4VS9Qdm43dXZwdXZjVXorMUUyOUVKR2FZL0dndnhUN0tyWU9SQTh3SitNdVRzUVZtanNlUnhveVJTejA4TmJ3ZTJIOGpXQnpFWWNxWWwyK0ZnK2hwNWd0S216VmhLRnBkNXZBNjdBSXo1NXN0QmNHNSswNHJVaWpFSzRzci9xa0x5QmtKQjdLdkwzanZKcG8zQjhxYkxYeXhLb1dSSmRnazhKNHMvTVp1QWk3QWUxUXNTTjl2Z3ZTdVRlc0VCUjVpSHJuS1lrbEpRWXNrbUQzbSsremE4U1NRbnBlM0UzYUZBY3p6cElUdUQ4YkFCWmRqcUk2TkhrSmFRQXBmb0hWNVQrZ244ejdUTWsrSStUU2JlQURubUxCS3lnMHRabW10L0ZKbDV6eWowVmxwc1dzTVM2OVE2bUZJVStqcEhSanpOb2FLMVM1dlQ3ZW1HbUhKSUp0cWlOdXJRN0tkQlBJPC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRWF6Q0NBdE9nQXdJQkFnSVVlN2EwODhDbnI0aXptcm5CRW54NXEzSFRNdll3RFFZSktvWklodmNOQVFFTEJRQXdSVEVMTUFrR0ExVUVCaE1DUjBJeEV6QVJCZ05WQkFnTUNsTnZiV1V0VTNSaGRHVXhJVEFmQmdOVkJBb01HRWx1ZEdWeWJtVjBJRmRwWkdkcGRITWdVSFI1SUV4MFpEQWVGdzB4T1RFeE1UWXhNakUzTVRWYUZ3MHlPVEV4TVRVeE1qRTNNVFZhTUVVeEN6QUpCZ05WQkFZVEFrZENNUk13RVFZRFZRUUlEQXBUYjIxbExWTjBZWFJsTVNFd0h3WURWUVFLREJoSmJuUmxjbTVsZENCWGFXUm5hWFJ6SUZCMGVTQk1kR1F3Z2dHaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQmp3QXdnZ0dLQW9JQmdRRHpMZTlGZmR5cGxUeEhwNFN1UTlnUXRaVDN0K1NEZnZFTDcycHBDZkZadzcrQjVzNUIvVDczYVhwb1EzUzUzcEdJMVJJV0NnZTJpQ1VRMnR6bTI3YVNOSDBpdTlhSlljVVFaL1JJVHFkMGF5eURrczFOQTJQVDNUVzZ0M203S1Y1cmU0UDBOYitZRGV1eUhka3oramNNdHBuOENtQm9UMEgrc2toYTBoaXFJTmtqa1JQaUh2TEhWR3ArdEhVRUEvSTZtTjRhQi9VRXhTVExzNzlOc0xVZnRlcXF4ZTkrdHZkVWFUb3lEUHJoUEZqT05zKzlOS0NreklDNnZjdjdKNkF0dUtHNm5FVCt6Qjl5T1dndEdZUWlmWHFRQTJ5NWRMODFCQjBxNXVNYUJMUzJwcTNhUFBqelUyRjMrRXlzanlTV1RuQ2tmazdDNVNzQ1hSdThRK1U5NXR1bnBOZndmNW9sRTZXYXM0OE5NTStQd1Y3aUNOTVBrTnpsbHE2UENpTStQOERyTVNjenpVWlpRVVN2NmRTd1BDbytZU1ZpbUVNME9nM1hKVGlOaFE1QU5sYUluNjZLdzVnZm9CZnVpWG15SUtpU0R5QWlEWW1GYWY0Mzk1d1d3TGtUUitjdzhXZmphSHN3S1pUb21uMU1SM09Kc1kyVUowZVJCWU0rWVNzQ0F3RUFBYU5UTUZFd0hRWURWUjBPQkJZRUZJbXAyQ1lDR2ZjYjd3OTFIL2NTaFRDa1h3Ui9NQjhHQTFVZEl3UVlNQmFBRkltcDJDWUNHZmNiN3c5MUgvY1NoVENrWHdSL01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dHQkFBK2cvQzd1TDlsbitXK3FCa25MVzgxa29qWWZsZ1BLMUkxTUhJd25NdmwvWlRIWDRkUlhLRHJrN0tjVXExS2pxYWpOVjY2ZjFjYWtwMDNJaWpCaU8wWGkxZ1hVWllMb0NpTkdVeXlwOVhsb2lJeTlYdzJQaVducncwK3laeXZWc3NiZWhYWFlKbDRSaWhCakJXdWw5UjR3TVlMT1VTSkRlMld4Y1VCaEpueHlOUnMrUDB4TFNRWDZCMm42bnhvRGtvNHAwN3M4WktYUWtlaVoyaXdGZFR4elJrR2p0aE1VdjcwNG56c1ZHQlQwRENQdGZTYU81S0paVzFyQ3MzeWlNdGhuQnhxNHFFRE9RSkZJbCsvTEQ3MUtiQjl2WmNXNUp1YXZ6QkZta0tHTnJvLzZHMUk3ZWw0NklSNHdpalR5TkZDWVV1RDlkdGlnbk5tcFd0TjhPVytwdGlML2p0VHlTV3VranlzMHMrdkxuODNDVnZqQjBkSnRWQUlZT2dYRmRJdWlpNjZnY3p3d00vTEdpT0V4Sm4wZFROenNKL0lZaHB4TDRGQkV1UDBwc2tZMG8wYVVsSjJMUzJqK3dTUVRSS3NCZ01qeXJVcmVrbGUyT0RTdFN0bjNlYWJqSXgwL0ZIbHBGcjBqTkltL29NUDdrd2p0VVg0emFOZTQ3UUk0R2c9PTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sOlN1YmplY3Q+PHNhbWw6TmFtZUlEIFNQTmFtZVF1YWxpZmllcj0iaHR0cDovL2Jvb2tzdGFjay5sb2NhbC9zYW1sMi9tZXRhZGF0YSIgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bmFtZWlkLWZvcm1hdDp0cmFuc2llbnQiPl8yYzdhYjg2ZWI4ZjFkMTA2MzQ0M2YyMTljYzU4NjhmZjY2NzA4OTEyZTM8L3NhbWw6TmFtZUlEPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb24gTWV0aG9kPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6Y206YmVhcmVyIj48c2FtbDpTdWJqZWN0Q29uZmlybWF0aW9uRGF0YSBOb3RPbk9yQWZ0ZXI9IjIwMTktMTEtMTdUMTc6NTg6MzlaIiBSZWNpcGllbnQ9Imh0dHA6Ly9ib29rc3RhY2subG9jYWwvc2FtbDIvYWNzIiBJblJlc3BvbnNlVG89Ik9ORUxPR0lOXzZhMGY0ZjM5OTMwNDBmMTk4N2ZkMzcwNjhiNTI5NjIyOWFkNTM2MWMiLz48L3NhbWw6U3ViamVjdENvbmZpcm1hdGlvbj48L3NhbWw6U3ViamVjdD48c2FtbDpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAxOS0xMS0xN1QxNzo1MzowOVoiIE5vdE9uT3JBZnRlcj0iMjAxOS0xMS0xN1QxNzo1ODozOVoiPjxzYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWw6QXVkaWVuY2U+aHR0cDovL2Jvb2tzdGFjay5sb2NhbC9zYW1sMi9tZXRhZGF0YTwvc2FtbDpBdWRpZW5jZT48L3NhbWw6QXVkaWVuY2VSZXN0cmljdGlvbj48L3NhbWw6Q29uZGl0aW9ucz48c2FtbDpBdXRoblN0YXRlbWVudCBBdXRobkluc3RhbnQ9IjIwMTktMTEtMTdUMTc6NTM6MzlaIiBTZXNzaW9uTm90T25PckFmdGVyPSIyMDE5LTExLTE4VDAxOjUzOjM5WiIgU2Vzc2lvbkluZGV4PSJfNGZlN2MwZDE1NzJkNjRiMjdmOTMwYWE2ZjIzNmE2ZjQyZTkzMDkwMWNjIj48c2FtbDpBdXRobkNvbnRleHQ+PHNhbWw6QXV0aG5Db250ZXh0Q2xhc3NSZWY+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFjOmNsYXNzZXM6UGFzc3dvcmQ8L3NhbWw6QXV0aG5Db250ZXh0Q2xhc3NSZWY+PC9zYW1sOkF1dGhuQ29udGV4dD48L3NhbWw6QXV0aG5TdGF0ZW1lbnQ+PHNhbWw6QXR0cmlidXRlU3RhdGVtZW50PjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJ1aWQiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6YmFzaWMiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhzaTp0eXBlPSJ4czpzdHJpbmciPnVzZXI8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iZmlyc3RfbmFtZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeHNpOnR5cGU9InhzOnN0cmluZyI+QmFycnk8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0ibGFzdF9uYW1lIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OmJhc2ljIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4c2k6dHlwZT0ieHM6c3RyaW5nIj5TY290dDwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJlbWFpbCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeHNpOnR5cGU9InhzOnN0cmluZyI+dXNlckBleGFtcGxlLmNvbTwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJ1c2VyX2dyb3VwcyIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeHNpOnR5cGU9InhzOnN0cmluZyI+bWVtYmVyPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhzaTp0eXBlPSJ4czpzdHJpbmciPmFkbWluPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PC9zYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48L3NhbWw6QXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+';
 
index 4369d8b7abca6faa84780f73c13bdd4176176aac..90d7e37aa5e85065588346e22e91fd319d5ccf80 100644 (file)
@@ -1,7 +1,11 @@
-<?php namespace Tests\Auth;
+<?php
 
+namespace Tests\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\SocialAccount;
 use BookStack\Auth\User;
-use DB;
+use Illuminate\Support\Facades\DB;
 use Laravel\Socialite\Contracts\Factory;
 use Laravel\Socialite\Contracts\Provider;
 use Mockery;
@@ -9,10 +13,9 @@ use Tests\TestCase;
 
 class SocialAuthTest extends TestCase
 {
-
     public function test_social_registration()
     {
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
 
         $this->setSettings(['registration-enabled' => 'true']);
         config(['GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']);
@@ -42,7 +45,7 @@ class SocialAuthTest extends TestCase
         config([
             'GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc',
             'GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc',
-            'APP_URL' => 'http://localhost'
+            'APP_URL'       => 'http://localhost',
         ]);
 
         $mockSocialite = $this->mock(Factory::class);
@@ -59,7 +62,7 @@ class SocialAuthTest extends TestCase
         // Test login routes
         $resp = $this->get('/login');
         $resp->assertElementExists('a#social-login-google[href$="/login/service/google"]');
-        $resp = $this->followingRedirects()->get("/login/service/google");
+        $resp = $this->followingRedirects()->get('/login/service/google');
         $resp->assertSee('login-form');
 
         // Test social callback
@@ -69,28 +72,53 @@ class SocialAuthTest extends TestCase
 
         $resp = $this->get('/login');
         $resp->assertElementExists('a#social-login-github[href$="/login/service/github"]');
-        $resp = $this->followingRedirects()->get("/login/service/github");
+        $resp = $this->followingRedirects()->get('/login/service/github');
         $resp->assertSee('login-form');
 
-
         // Test social callback with matching social account
         DB::table('social_accounts')->insert([
-            'user_id' => $this->getAdmin()->id,
-            'driver' => 'github',
-            'driver_id' => 'logintest123'
+            'user_id'   => $this->getAdmin()->id,
+            'driver'    => 'github',
+            'driver_id' => 'logintest123',
         ]);
         $resp = $this->followingRedirects()->get('/login/service/github/callback');
-        $resp->assertDontSee("login-form");
+        $resp->assertDontSee('login-form');
+        $this->assertActivityExists(ActivityType::AUTH_LOGIN, null, 'github; (' . $this->getAdmin()->id . ') ' . $this->getAdmin()->name);
+    }
+
+    public function test_social_account_detach()
+    {
+        $editor = $this->getEditor();
+        config([
+            'GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc',
+            'APP_URL'       => 'http://localhost',
+        ]);
+
+        $socialAccount = SocialAccount::query()->forceCreate([
+            'user_id'   => $editor->id,
+            'driver'    => 'github',
+            'driver_id' => 'logintest123',
+        ]);
+
+        $resp = $this->actingAs($editor)->get($editor->getEditUrl());
+        $resp->assertElementContains('form[action$="/login/service/github/detach"]', 'Disconnect Account');
+
+        $resp = $this->post('/login/service/github/detach');
+        $resp->assertRedirect($editor->getEditUrl());
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('Github account was successfully disconnected from your profile.');
+
+        $this->assertDatabaseMissing('social_accounts', ['id' => $socialAccount->id]);
     }
 
     public function test_social_autoregister()
     {
         config([
             'services.google.client_id' => 'abc123', 'services.google.client_secret' => '123abc',
-            'APP_URL' => 'http://localhost'
+            'APP_URL'                   => 'http://localhost',
         ]);
 
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
         $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
@@ -125,10 +153,10 @@ class SocialAuthTest extends TestCase
     {
         config([
             'services.google.client_id' => 'abc123', 'services.google.client_secret' => '123abc',
-            'APP_URL' => 'http://localhost', 'services.google.auto_register' => true, 'services.google.auto_confirm' => true
+            'APP_URL'                   => 'http://localhost', 'services.google.auto_register' => true, 'services.google.auto_confirm' => true,
         ]);
 
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
         $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
@@ -160,7 +188,7 @@ class SocialAuthTest extends TestCase
 
     public function test_social_registration_with_no_name_uses_email_as_name()
     {
-        $user = factory(User::class)->make(['email' => '[email protected]']);
+        $user = User::factory()->make(['email' => '[email protected]']);
 
         $this->setSettings(['registration-enabled' => 'true']);
         config(['GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']);
@@ -184,5 +212,4 @@ class SocialAuthTest extends TestCase
         $user = $user->whereEmail($user->email)->first();
         $this->assertDatabaseHas('social_accounts', ['user_id' => $user->id]);
     }
-
 }
index b6f521eaa23196466114b27a04d5cca41b885115..922a98ef3d438235bcf8444d23eb6c64a91990e4 100644 (file)
@@ -1,18 +1,19 @@
-<?php namespace Tests\Auth;
+<?php
 
+namespace Tests\Auth;
 
 use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\User;
 use BookStack\Notifications\UserInvite;
 use Carbon\Carbon;
-use DB;
+use Illuminate\Notifications\Messages\MailMessage;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Notification;
 use Illuminate\Support\Str;
-use Notification;
 use Tests\TestCase;
 
 class UserInviteTest extends TestCase
 {
-
     public function test_user_creation_creates_invite()
     {
         Notification::fake();
@@ -20,8 +21,8 @@ class UserInviteTest extends TestCase
 
         $email = Str::random(16) . '@example.com';
         $resp = $this->actingAs($admin)->post('/settings/users/create', [
-            'name' => 'Barry',
-            'email' => $email,
+            'name'        => 'Barry',
+            'email'       => $email,
             'send_invite' => 'true',
         ]);
         $resp->assertRedirect('/settings/users');
@@ -30,8 +31,34 @@ class UserInviteTest extends TestCase
 
         Notification::assertSentTo($newUser, UserInvite::class);
         $this->assertDatabaseHas('user_invites', [
-            'user_id' => $newUser->id
+            'user_id' => $newUser->id,
+        ]);
+    }
+
+    public function test_user_invite_sent_in_selected_language()
+    {
+        Notification::fake();
+        $admin = $this->getAdmin();
+
+        $email = Str::random(16) . '@example.com';
+        $resp = $this->actingAs($admin)->post('/settings/users/create', [
+            'name'        => 'Barry',
+            'email'       => $email,
+            'send_invite' => 'true',
+            'setting'     => [
+                'language' => 'de',
+            ],
         ]);
+        $resp->assertRedirect('/settings/users');
+
+        $newUser = User::query()->where('email', '=', $email)->orderBy('id', 'desc')->first();
+        Notification::assertSentTo($newUser, UserInvite::class, function ($notification, $channels, $notifiable) {
+            /** @var MailMessage $mail */
+            $mail = $notification->toMail($notifiable);
+
+            return 'Du wurdest eingeladen BookStack beizutreten!' === $mail->subject &&
+                'Ein Konto wurde für Sie auf BookStack erstellt.' === $mail->greeting;
+        });
     }
 
     public function test_invite_set_password()
@@ -52,14 +79,14 @@ class UserInviteTest extends TestCase
         $setPasswordResp = $this->followingRedirects()->post('/register/invite/' . $token, [
             'password' => 'my test password',
         ]);
-        $setPasswordResp->assertSee('Password set, you now have access to BookStack!');
+        $setPasswordResp->assertSee('Password set, you should now be able to login using your set password to access BookStack!');
         $newPasswordValid = auth()->validate([
-            'email' => $user->email,
-            'password' => 'my test password'
+            'email'    => $user->email,
+            'password' => 'my test password',
         ]);
         $this->assertTrue($newPasswordValid);
         $this->assertDatabaseMissing('user_invites', [
-            'user_id' => $user->id
+            'user_id' => $user->id,
         ]);
     }
 
@@ -85,7 +112,7 @@ class UserInviteTest extends TestCase
         $noPassword->assertSee('The password field is required.');
 
         $this->assertDatabaseHas('user_invites', [
-            'user_id' => $user->id
+            'user_id' => $user->id,
         ]);
     }
 
@@ -112,6 +139,4 @@ class UserInviteTest extends TestCase
         $setPasswordPageResp->assertRedirect('/password/email');
         $setPasswordPageResp->assertSessionHas('error', 'This invitation link has expired. You can instead try to reset your account password.');
     }
-
-
-}
\ No newline at end of file
+}
diff --git a/tests/BrowserKitTest.php b/tests/BrowserKitTest.php
deleted file mode 100644 (file)
index 45e20b5..0000000
+++ /dev/null
@@ -1,207 +0,0 @@
-<?php namespace Tests;
-
-use BookStack\Auth\User;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Entity;
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Models\Page;
-use BookStack\Settings\SettingService;
-use DB;
-use Illuminate\Contracts\Console\Kernel;
-use Illuminate\Foundation\Application;
-use Illuminate\Foundation\Testing\DatabaseTransactions;
-use Laravel\BrowserKitTesting\TestCase;
-use Symfony\Component\DomCrawler\Crawler;
-
-abstract class BrowserKitTest extends TestCase
-{
-
-    use DatabaseTransactions;
-    use SharedTestHelpers;
-
-    /**
-     * The base URL to use while testing the application.
-     * @var string
-     */
-    protected $baseUrl = 'http://localhost';
-
-    public function tearDown() : void
-    {
-        DB::disconnect();
-        parent::tearDown();
-    }
-
-    /**
-     * Creates the application.
-     *
-     * @return Application
-     */
-    public function createApplication()
-    {
-        $app = require __DIR__.'/../bootstrap/app.php';
-
-        $app->make(Kernel::class)->bootstrap();
-
-        return $app;
-    }
-
-
-    /**
-     * Get a user that's not a system user such as the guest user.
-     */
-    public function getNormalUser()
-    {
-        return User::where('system_name', '=', null)->get()->last();
-    }
-
-    /**
-     * Quickly sets an array of settings.
-     * @param $settingsArray
-     */
-    protected function setSettings($settingsArray)
-    {
-        $settings = app(SettingService::class);
-        foreach ($settingsArray as $key => $value) {
-            $settings->put($key, $value);
-        }
-    }
-
-    /**
-     * Create a group of entities that belong to a specific user.
-     */
-    protected function createEntityChainBelongingToUser(User $creatorUser, ?User $updaterUser = null): array
-    {
-        if (empty($updaterUser)) {
-            $updaterUser = $creatorUser;
-        }
-
-        $userAttrs = ['created_by' => $creatorUser->id, 'owned_by' => $creatorUser->id, 'updated_by' => $updaterUser->id];
-        $book = factory(Book::class)->create($userAttrs);
-        $chapter = factory(Chapter::class)->create(array_merge(['book_id' => $book->id], $userAttrs));
-        $page = factory(Page::class)->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs));
-        $restrictionService = $this->app[PermissionService::class];
-        $restrictionService->buildJointPermissionsForEntity($book);
-
-        return compact('book', 'chapter', 'page');
-    }
-
-    /**
-     * Helper for updating entity permissions.
-     * @param Entity $entity
-     */
-    protected function updateEntityPermissions(Entity $entity)
-    {
-        $restrictionService = $this->app[PermissionService::class];
-        $restrictionService->buildJointPermissionsForEntity($entity);
-    }
-
-
-    /**
-     * Quick way to create a new user without any permissions
-     * @param array $attributes
-     * @return mixed
-     */
-    protected function getNewBlankUser($attributes = [])
-    {
-        $user = factory(User::class)->create($attributes);
-        return $user;
-    }
-
-    /**
-     * Assert that a given string is seen inside an element.
-     *
-     * @param  bool|string|null $element
-     * @param  integer          $position
-     * @param  string           $text
-     * @param  bool             $negate
-     * @return $this
-     */
-    protected function seeInNthElement($element, $position, $text, $negate = false)
-    {
-        $method = $negate ? 'assertDoesNotMatchRegularExpression' : 'assertMatchesRegularExpression';
-
-        $rawPattern = preg_quote($text, '/');
-
-        $escapedPattern = preg_quote(e($text), '/');
-
-        $content = $this->crawler->filter($element)->eq($position)->html();
-
-        $pattern = $rawPattern == $escapedPattern
-            ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
-
-        $this->$method("/$pattern/i", $content);
-
-        return $this;
-    }
-
-    /**
-     * Assert that the current page matches a given URI.
-     *
-     * @param  string  $uri
-     * @return $this
-     */
-    protected function seePageUrlIs($uri)
-    {
-        $this->assertEquals(
-            $uri, $this->currentUri, "Did not land on expected page [{$uri}].\n"
-        );
-
-        return $this;
-    }
-
-    /**
-     * Do a forced visit that does not error out on exception.
-     * @param string $uri
-     * @param array $parameters
-     * @param array $cookies
-     * @param array $files
-     * @return $this
-     */
-    protected function forceVisit($uri, $parameters = [], $cookies = [], $files = [])
-    {
-        $method = 'GET';
-        $uri = $this->prepareUrlForRequest($uri);
-        $this->call($method, $uri, $parameters, $cookies, $files);
-        $this->clearInputs()->followRedirects();
-        $this->currentUri = $this->app->make('request')->fullUrl();
-        $this->crawler = new Crawler($this->response->getContent(), $uri);
-        return $this;
-    }
-
-    /**
-     * Click the text within the selected element.
-     * @param $parentElement
-     * @param $linkText
-     * @return $this
-     */
-    protected function clickInElement($parentElement, $linkText)
-    {
-        $elem = $this->crawler->filter($parentElement);
-        $link = $elem->selectLink($linkText);
-        $this->visit($link->link()->getUri());
-        return $this;
-    }
-
-    /**
-     * Check if the page contains the given element.
-     * @param  string  $selector
-     */
-    protected function pageHasElement($selector)
-    {
-        $elements = $this->crawler->filter($selector);
-        $this->assertTrue(count($elements) > 0, "The page does not contain an element matching " . $selector);
-        return $this;
-    }
-
-    /**
-     * Check if the page contains the given element.
-     * @param  string  $selector
-     */
-    protected function pageNotHasElement($selector)
-    {
-        $elements = $this->crawler->filter($selector);
-        $this->assertFalse(count($elements) > 0, "The page contains " . count($elements) . " elements matching " . $selector);
-        return $this;
-    }
-}
diff --git a/tests/Commands/AddAdminCommandTest.php b/tests/Commands/AddAdminCommandTest.php
deleted file mode 100644 (file)
index 6b03c86..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php namespace Tests\Commands;
-
-use BookStack\Auth\User;
-use Tests\TestCase;
-
-class AddAdminCommandTest extends TestCase
-{
-    public function test_add_admin_command()
-    {
-        $exitCode = \Artisan::call('bookstack:create-admin', [
-            '--email' => '[email protected]',
-            '--name' => 'Admin Test',
-            '--password' => 'testing-4',
-        ]);
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseHas('users', [
-            'email' => '[email protected]',
-            'name' => 'Admin Test'
-        ]);
-
-        $this->assertTrue(User::query()->where('email', '=', '[email protected]')->first()->hasSystemRole('admin'), 'User has admin role as expected');
-        $this->assertTrue(\Auth::attempt(['email' => '[email protected]', 'password' => 'testing-4']), 'Password stored as expected');
-    }
-}
\ No newline at end of file
index 751a165c65c70e2085d9c598a1d816b3d4e2771b..71baa0ca667cb0a913074bd1ca95cf4ccb2b809a 100644 (file)
@@ -1,7 +1,11 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Page;
+use BookStack\Facades\Activity;
+use Illuminate\Support\Facades\Artisan;
 use Illuminate\Support\Facades\DB;
 use Tests\TestCase;
 
@@ -10,24 +14,23 @@ class ClearActivityCommandTest extends TestCase
     public function test_clear_activity_command()
     {
         $this->asEditor();
-        $page = Page::first();
-        \Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        Activity::add(ActivityType::PAGE_UPDATE, $page);
 
         $this->assertDatabaseHas('activities', [
-            'type' => 'page_update',
+            'type'      => 'page_update',
             'entity_id' => $page->id,
-            'user_id' => $this->getEditor()->id
+            'user_id'   => $this->getEditor()->id,
         ]);
 
-
         DB::rollBack();
-        $exitCode = \Artisan::call('bookstack:clear-activity');
+        $exitCode = Artisan::call('bookstack:clear-activity');
         DB::beginTransaction();
         $this->assertTrue($exitCode === 0, 'Command executed successfully');
 
-
         $this->assertDatabaseMissing('activities', [
-            'type' => 'page_update'
+            'type' => 'page_update',
         ]);
     }
-}
\ No newline at end of file
+}
index e0293faf165433e3ebcc7b70e934411283894ea6..a7aef958fc98725f79ad9b1107bc2f32813c8774 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
@@ -17,11 +19,11 @@ class ClearRevisionsCommandTest extends TestCase
 
         $this->assertDatabaseHas('page_revisions', [
             'page_id' => $page->id,
-            'type' => 'version'
+            'type'    => 'version',
         ]);
         $this->assertDatabaseHas('page_revisions', [
             'page_id' => $page->id,
-            'type' => 'update_draft'
+            'type'    => 'update_draft',
         ]);
 
         $exitCode = Artisan::call('bookstack:clear-revisions');
@@ -29,11 +31,11 @@ class ClearRevisionsCommandTest extends TestCase
 
         $this->assertDatabaseMissing('page_revisions', [
             'page_id' => $page->id,
-            'type' => 'version'
+            'type'    => 'version',
         ]);
         $this->assertDatabaseHas('page_revisions', [
             'page_id' => $page->id,
-            'type' => 'update_draft'
+            'type'    => 'update_draft',
         ]);
 
         $exitCode = Artisan::call('bookstack:clear-revisions', ['--all' => true]);
@@ -41,7 +43,7 @@ class ClearRevisionsCommandTest extends TestCase
 
         $this->assertDatabaseMissing('page_revisions', [
             'page_id' => $page->id,
-            'type' => 'update_draft'
+            'type'    => 'update_draft',
         ]);
     }
-}
\ No newline at end of file
+}
index 04665adcf2b384b200b2b728555b47e91a56168a..bbd06fa01086aeeb9fa3134669975ff9af83e2c7 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Facades\DB;
@@ -6,7 +8,6 @@ use Tests\TestCase;
 
 class ClearViewsCommandTest extends TestCase
 {
-
     public function test_clear_views_command()
     {
         $this->asEditor();
@@ -15,9 +16,9 @@ class ClearViewsCommandTest extends TestCase
         $this->get($page->getUrl());
 
         $this->assertDatabaseHas('views', [
-            'user_id' => $this->getEditor()->id,
+            'user_id'     => $this->getEditor()->id,
             'viewable_id' => $page->id,
-            'views' => 1
+            'views'       => 1,
         ]);
 
         DB::rollBack();
@@ -26,7 +27,7 @@ class ClearViewsCommandTest extends TestCase
         $this->assertTrue($exitCode === 0, 'Command executed successfully');
 
         $this->assertDatabaseMissing('views', [
-            'user_id' => $this->getEditor()->id
+            'user_id' => $this->getEditor()->id,
         ]);
     }
-}
\ No newline at end of file
+}
index 87199bdc3ead7893671d1050cebd0c93cc9299c8..5a60b8d55c550ea4b01164f40e1f150199117488 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Entities\Models\Bookshelf;
 use Tests\TestCase;
@@ -17,8 +19,8 @@ class CopyShelfPermissionsCommandTest extends TestCase
         $shelf = Bookshelf::first();
         $child = $shelf->books()->first();
         $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
-        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+        $this->assertFalse(boolval($child->restricted), 'Child book should not be restricted by default');
+        $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default');
 
         $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
         $this->artisan('bookstack:copy-shelf-permissions', [
@@ -26,8 +28,8 @@ class CopyShelfPermissionsCommandTest extends TestCase
         ]);
         $child = $shelf->books()->first();
 
-        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
-        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertTrue(boolval($child->restricted), 'Child book should now be restricted');
+        $this->assertTrue($child->permissions()->count() === 2, 'Child book should have copied permissions');
         $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
         $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
     }
@@ -38,17 +40,17 @@ class CopyShelfPermissionsCommandTest extends TestCase
         Bookshelf::query()->where('id', '!=', $shelf->id)->delete();
         $child = $shelf->books()->first();
         $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
-        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+        $this->assertFalse(boolval($child->restricted), 'Child book should not be restricted by default');
+        $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default');
 
         $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
         $this->artisan('bookstack:copy-shelf-permissions --all')
             ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y');
         $child = $shelf->books()->first();
 
-        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
-        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertTrue(boolval($child->restricted), 'Child book should now be restricted');
+        $this->assertTrue($child->permissions()->count() === 2, 'Child book should have copied permissions');
         $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
         $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/Commands/CreateAdminCommandTest.php b/tests/Commands/CreateAdminCommandTest.php
new file mode 100644 (file)
index 0000000..1d8915b
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Auth\User;
+use Illuminate\Support\Facades\Auth;
+use Tests\TestCase;
+
+class CreateAdminCommandTest extends TestCase
+{
+    public function test_standard_command_usage()
+    {
+        $this->artisan('bookstack:create-admin', [
+            '--email'    => '[email protected]',
+            '--name'     => 'Admin Test',
+            '--password' => 'testing-4',
+        ])->assertExitCode(0);
+
+        $this->assertDatabaseHas('users', [
+            'email' => '[email protected]',
+            'name'  => 'Admin Test',
+        ]);
+
+        /** @var User $user */
+        $user = User::query()->where('email', '=', '[email protected]')->first();
+        $this->assertTrue($user->hasSystemRole('admin'));
+        $this->assertTrue(Auth::attempt(['email' => '[email protected]', 'password' => 'testing-4']));
+    }
+
+    public function test_providing_external_auth_id()
+    {
+        $this->artisan('bookstack:create-admin', [
+            '--email'            => '[email protected]',
+            '--name'             => 'Admin Test',
+            '--external-auth-id' => 'xX_admin_Xx',
+        ])->assertExitCode(0);
+
+        $this->assertDatabaseHas('users', [
+            'email'            => '[email protected]',
+            'name'             => 'Admin Test',
+            'external_auth_id' => 'xX_admin_Xx',
+        ]);
+
+        /** @var User $user */
+        $user = User::query()->where('email', '=', '[email protected]')->first();
+        $this->assertNotEmpty($user->password);
+    }
+
+    public function test_password_required_if_external_auth_id_not_given()
+    {
+        $this->artisan('bookstack:create-admin', [
+            '--email' => '[email protected]',
+            '--name'  => 'Admin Test',
+        ])->expectsQuestion('Please specify a password for the new admin user (8 characters min)', 'hunter2000')
+            ->assertExitCode(0);
+
+        $this->assertDatabaseHas('users', [
+            'email' => '[email protected]',
+            'name'  => 'Admin Test',
+        ]);
+        $this->assertTrue(Auth::attempt(['email' => '[email protected]', 'password' => 'hunter2000']));
+    }
+}
index 1deeaa703a114fc39c471a181e173afd2e7d0b5b..08f137777d191af0601e83cbd5e0a011eb06be1e 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Actions\Comment;
 use Tests\TestCase;
@@ -26,4 +28,4 @@ class RegenerateCommentContentCommandTest extends TestCase
             'html' => "<p>some_fresh_content</p>\n",
         ]);
     }
-}
\ No newline at end of file
+}
index d5b34ba170c4e8c41aaf826061f7df6646d93445..2090c991bebbf4bcd4b363f4a31ff0363f5f38cc 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Entities\Models\Page;
@@ -21,4 +23,4 @@ class RegeneratePermissionsCommandTest extends TestCase
 
         $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/Commands/ResetMfaCommandTest.php b/tests/Commands/ResetMfaCommandTest.php
new file mode 100644 (file)
index 0000000..e65a048
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Auth\Access\Mfa\MfaValue;
+use BookStack\Auth\User;
+use Tests\TestCase;
+
+class ResetMfaCommandTest extends TestCase
+{
+    public function test_command_requires_email_or_id_option()
+    {
+        $this->artisan('bookstack:reset-mfa')
+            ->expectsOutput('Either a --id=<number> or --email=<email> option must be provided.')
+            ->assertExitCode(1);
+    }
+
+    public function test_command_runs_with_provided_email()
+    {
+        /** @var User $user */
+        $user = User::query()->first();
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'test');
+
+        $this->assertEquals(1, $user->mfaValues()->count());
+        $this->artisan("bookstack:reset-mfa --email={$user->email}")
+            ->expectsQuestion('Are you sure you want to proceed?', true)
+            ->expectsOutput('User MFA methods have been reset.')
+            ->assertExitCode(0);
+        $this->assertEquals(0, $user->mfaValues()->count());
+    }
+
+    public function test_command_runs_with_provided_id()
+    {
+        /** @var User $user */
+        $user = User::query()->first();
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'test');
+
+        $this->assertEquals(1, $user->mfaValues()->count());
+        $this->artisan("bookstack:reset-mfa --id={$user->id}")
+            ->expectsQuestion('Are you sure you want to proceed?', true)
+            ->expectsOutput('User MFA methods have been reset.')
+            ->assertExitCode(0);
+        $this->assertEquals(0, $user->mfaValues()->count());
+    }
+
+    public function test_saying_no_to_confirmation_does_not_reset_mfa()
+    {
+        /** @var User $user */
+        $user = User::query()->first();
+        MfaValue::upsertWithValue($user, MfaValue::METHOD_TOTP, 'test');
+
+        $this->assertEquals(1, $user->mfaValues()->count());
+        $this->artisan("bookstack:reset-mfa --id={$user->id}")
+            ->expectsQuestion('Are you sure you want to proceed?', false)
+            ->assertExitCode(1);
+        $this->assertEquals(1, $user->mfaValues()->count());
+    }
+
+    public function test_giving_non_existing_user_shows_error_message()
+    {
+        $this->artisan('bookstack:reset-mfa [email protected]')
+            ->expectsOutput('A user where [email protected] could not be found.')
+            ->assertExitCode(1);
+    }
+}
index 7043ce047f17bc09d829f852e1f267403228cf11..0acccd80cc2adc14a4e2254247002974fc02a583 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Commands;
+<?php
+
+namespace Tests\Commands;
 
 use BookStack\Entities\Models\Page;
 use Symfony\Component\Console\Exception\RuntimeException;
@@ -14,17 +16,17 @@ class UpdateUrlCommandTest extends TestCase
 
         $this->artisan('bookstack:update-url https://example.com https://cats.example.com')
             ->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');
+            ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
 
         $this->assertDatabaseHas('pages', [
-            'id' => $page->id,
-            'html' => '<a href="https://cats.example.com/donkeys"></a>'
+            'id'   => $page->id,
+            'html' => '<a href="https://cats.example.com/donkeys"></a>',
         ]);
     }
 
     public function test_command_requires_valid_url()
     {
-        $badUrlMessage = "The given urls are expected to be full urls starting with http:// or https://";
+        $badUrlMessage = 'The given urls are expected to be full urls starting with http:// or https://';
         $this->artisan('bookstack:update-url //example.com https://cats.example.com')->expectsOutput($badUrlMessage);
         $this->artisan('bookstack:update-url https://example.com htts://cats.example.com')->expectsOutput($badUrlMessage);
         $this->artisan('bookstack:update-url example.com https://cats.example.com')->expectsOutput($badUrlMessage);
@@ -54,6 +56,6 @@ class UpdateUrlCommandTest extends TestCase
     {
         $this->artisan("bookstack:update-url {$oldUrl} {$newUrl}")
             ->expectsQuestion("This will search for \"{$oldUrl}\" in your database and replace it with  \"{$newUrl}\".\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');
+            ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y');
     }
-}
\ No newline at end of file
+}
index 42a5da2d16ab402a976d43deab9fe03877518c72..b1cefbb6584375b519bcc230f434464f1ca87b4a 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 use Illuminate\Contracts\Console\Kernel;
 
@@ -11,8 +13,9 @@ trait CreatesApplication
      */
     public function createApplication()
     {
-        $app = require __DIR__.'/../bootstrap/app.php';
+        $app = require __DIR__ . '/../bootstrap/app.php';
         $app->make(Kernel::class)->bootstrap();
+
         return $app;
     }
-}
\ No newline at end of file
+}
diff --git a/tests/DebugViewTest.php b/tests/DebugViewTest.php
new file mode 100644 (file)
index 0000000..63d6472
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+
+namespace Tests;
+
+use BookStack\Auth\Access\SocialAuthService;
+
+class DebugViewTest extends TestCase
+{
+    public function test_debug_view_shows_expected_details()
+    {
+        config()->set('app.debug', true);
+        $resp = $this->getDebugViewForException(new \InvalidArgumentException('An error occurred during testing'));
+
+        // Error message
+        $resp->assertSeeText('An error occurred during testing');
+        // Exception Class
+        $resp->assertSeeText('InvalidArgumentException');
+        // Stack trace
+        $resp->assertSeeText('#0');
+        $resp->assertSeeText('#1');
+        // Warning message
+        $resp->assertSeeText('WARNING: Application is in debug mode. This mode has the potential to leak');
+        // PHP version
+        $resp->assertSeeText('PHP Version: ' . phpversion());
+        // BookStack version
+        $resp->assertSeeText('BookStack Version: ' . trim(file_get_contents(base_path('version'))));
+        // Dynamic help links
+        $resp->assertElementExists('a[href*="q=' . urlencode('BookStack An error occurred during testing') . '"]');
+        $resp->assertElementExists('a[href*="?q=is%3Aissue+' . urlencode('An error occurred during testing') . '"]');
+    }
+
+    public function test_debug_view_only_shows_when_debug_mode_is_enabled()
+    {
+        config()->set('app.debug', true);
+        $resp = $this->getDebugViewForException(new \InvalidArgumentException('An error occurred during testing'));
+        $resp->assertSeeText('Stack Trace');
+        $resp->assertDontSeeText('An unknown error occurred');
+
+        config()->set('app.debug', false);
+        $resp = $this->getDebugViewForException(new \InvalidArgumentException('An error occurred during testing'));
+        $resp->assertDontSeeText('Stack Trace');
+        $resp->assertSeeText('An unknown error occurred');
+    }
+
+    protected function getDebugViewForException(\Exception $exception): TestResponse
+    {
+        // Fake an error via social auth service used on login page
+        $mockService = $this->mock(SocialAuthService::class);
+        $mockService->shouldReceive('getActiveDrivers')->andThrow($exception);
+
+        return $this->get('/login');
+    }
+}
index 60658f6b2b8a87ecf36609a32f79bda7b46434e6..fcbc17ea94ba40ce4c0c89f811234cc829786bd3 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
@@ -10,7 +12,6 @@ use Tests\Uploads\UsesImages;
 
 class BookShelfTest extends TestCase
 {
-
     use UsesImages;
 
     public function test_shelves_shows_in_header_if_have_view_permissions()
@@ -36,7 +37,7 @@ class BookShelfTest extends TestCase
 
     public function test_shelves_shows_in_header_if_have_any_shelve_view_permission()
     {
-        $user = factory(User::class)->create();
+        $user = User::factory()->create();
         $this->giveUserPermissions($user, ['image-create-all']);
         $shelf = Bookshelf::first();
         $userRole = $user->roles()->first();
@@ -79,16 +80,16 @@ class BookShelfTest extends TestCase
     {
         $booksToInclude = Book::take(2)->get();
         $shelfInfo = [
-            'name' => 'My test book' . Str::random(4),
-            'description' => 'Test book description ' . Str::random(10)
+            'name'        => 'My test book' . Str::random(4),
+            'description' => 'Test book description ' . Str::random(10),
         ];
         $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [
             'books' => $booksToInclude->implode('id', ','),
-            'tags' => [
+            'tags'  => [
                 [
-                    'name' => 'Test Category',
+                    'name'  => 'Test Category',
                     'value' => 'Test Tag Value',
-                ]
+                ],
             ],
         ]));
         $resp->assertRedirect();
@@ -109,8 +110,8 @@ class BookShelfTest extends TestCase
     public function test_shelves_create_sets_cover_image()
     {
         $shelfInfo = [
-            'name' => 'My test book' . Str::random(4),
-            'description' => 'Test book description ' . Str::random(10)
+            'name'        => 'My test book' . Str::random(4),
+            'description' => 'Test book description ' . Str::random(10),
         ];
 
         $imageFile = $this->getTestImage('shelf-test.png');
@@ -120,7 +121,7 @@ class BookShelfTest extends TestCase
         $lastImage = Image::query()->orderByDesc('id')->firstOrFail();
         $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first();
         $this->assertDatabaseHas('bookshelves', [
-            'id' => $shelf->id,
+            'id'       => $shelf->id,
             'image_id' => $lastImage->id,
         ]);
         $this->assertEquals($lastImage->id, $shelf->cover->id);
@@ -175,7 +176,7 @@ class BookShelfTest extends TestCase
         // Set book ordering
         $this->asAdmin()->put($shelf->getUrl(), [
             'books' => $books->implode('id', ','),
-            'tags' => [], 'description' => 'abc', 'name' => 'abc'
+            'tags'  => [], 'description' => 'abc', 'name' => 'abc',
         ]);
         $this->assertEquals(3, $shelf->books()->count());
         $shelf->refresh();
@@ -205,17 +206,17 @@ class BookShelfTest extends TestCase
 
         $booksToInclude = Book::take(2)->get();
         $shelfInfo = [
-            'name' => 'My test book' . Str::random(4),
-            'description' => 'Test book description ' . Str::random(10)
+            'name'        => 'My test book' . Str::random(4),
+            'description' => 'Test book description ' . Str::random(10),
         ];
 
         $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [
             'books' => $booksToInclude->implode('id', ','),
-            'tags' => [
+            'tags'  => [
                 [
-                    'name' => 'Test Category',
+                    'name'  => 'Test Category',
                     'value' => 'Test Tag Value',
-                ]
+                ],
             ],
         ]));
         $shelf = Bookshelf::find($shelf->id);
@@ -246,15 +247,15 @@ class BookShelfTest extends TestCase
         $testName = 'Test Book in Shelf Name';
 
         $createBookResp = $this->asEditor()->post($shelf->getUrl('/create-book'), [
-            'name' => $testName,
-            'description' => 'Book in shelf description'
+            'name'        => $testName,
+            'description' => 'Book in shelf description',
         ]);
         $createBookResp->assertRedirect();
 
         $newBook = Book::query()->orderBy('id', 'desc')->first();
         $this->assertDatabaseHas('bookshelves_books', [
             'bookshelf_id' => $shelf->id,
-            'book_id' => $newBook->id,
+            'book_id'      => $newBook->id,
         ]);
 
         $resp = $this->asEditor()->get($shelf->getUrl());
@@ -289,24 +290,31 @@ class BookShelfTest extends TestCase
         $shelf = Bookshelf::first();
         $resp = $this->asAdmin()->get($shelf->getUrl('/permissions'));
         $resp->assertSeeText('Copy Permissions');
-        $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\"");
+        $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\"", false);
 
         $child = $shelf->books()->first();
         $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
-        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+        $this->assertFalse(boolval($child->restricted), 'Child book should not be restricted by default');
+        $this->assertTrue($child->permissions()->count() === 0, 'Child book should have no permissions by default');
 
         $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
         $resp = $this->post($shelf->getUrl('/copy-permissions'));
         $child = $shelf->books()->first();
 
         $resp->assertRedirect($shelf->getUrl());
-        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
-        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertTrue(boolval($child->restricted), 'Child book should now be restricted');
+        $this->assertTrue($child->permissions()->count() === 2, 'Child book should have copied permissions');
         $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
         $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
     }
 
+    public function test_permission_page_has_a_warning_about_no_cascading()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl('/permissions'));
+        $resp->assertSeeText('Permissions on bookshelves do not automatically cascade to contained books.');
+    }
+
     public function test_bookshelves_show_in_breadcrumbs_if_in_context()
     {
         $shelf = Bookshelf::first();
@@ -337,8 +345,8 @@ class BookShelfTest extends TestCase
     {
         // Create shelf
         $shelfInfo = [
-            'name' => 'My test shelf' . Str::random(4),
-            'description' => 'Test shelf description ' . Str::random(10)
+            'name'        => 'My test shelf' . Str::random(4),
+            'description' => 'Test shelf description ' . Str::random(10),
         ];
 
         $this->asEditor()->post('/shelves', $shelfInfo);
@@ -346,8 +354,8 @@ class BookShelfTest extends TestCase
 
         // Create book and add to shelf
         $this->asEditor()->post($shelf->getUrl('/create-book'), [
-            'name' => 'Test book name',
-            'description' => 'Book in shelf description'
+            'name'        => 'Test book name',
+            'description' => 'Book in shelf description',
         ]);
 
         $newBook = Book::query()->orderBy('id', 'desc')->first();
@@ -361,4 +369,12 @@ class BookShelfTest extends TestCase
         $resp = $this->asEditor()->get($newBook->getUrl());
         $resp->assertDontSee($shelfInfo['name']);
     }
+
+    public function test_cancel_on_child_book_creation_returns_to_original_shelf()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/create-book'));
+        $resp->assertElementContains('form a[href="' . $shelf->getUrl() . '"]', 'Cancel');
+    }
 }
index 6c2cf30d416f9126880a3611b2d6dcd039f3cab5..7f102a17eaede81e70792b8130f091444da0cdd9 100644 (file)
@@ -1,11 +1,80 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Repos\BookRepo;
 use Tests\TestCase;
+use Tests\Uploads\UsesImages;
 
 class BookTest extends TestCase
 {
-    public function test_book_delete()
+    use UsesImages;
+
+    public function test_create()
+    {
+        $book = Book::factory()->make([
+            'name' => 'My First Book',
+        ]);
+
+        $resp = $this->asEditor()->get('/books');
+        $resp->assertElementContains('a[href="' . url('/create-book') . '"]', 'Create New Book');
+
+        $resp = $this->get('/create-book');
+        $resp->assertElementContains('form[action="' . url('/books') . '"][method="POST"]', 'Save Book');
+
+        $resp = $this->post('/books', $book->only('name', 'description'));
+        $resp->assertRedirect('/books/my-first-book');
+
+        $resp = $this->get('/books/my-first-book');
+        $resp->assertSee($book->name);
+        $resp->assertSee($book->description);
+    }
+
+    public function test_create_uses_different_slugs_when_name_reused()
+    {
+        $book = Book::factory()->make([
+            'name' => 'My First Book',
+        ]);
+
+        $this->asEditor()->post('/books', $book->only('name', 'description'));
+        $this->asEditor()->post('/books', $book->only('name', 'description'));
+
+        $books = Book::query()->where('name', '=', $book->name)
+            ->orderBy('id', 'desc')
+            ->take(2)
+            ->get();
+
+        $this->assertMatchesRegularExpression('/my-first-book-[0-9a-zA-Z]{3}/', $books[0]->slug);
+        $this->assertEquals('my-first-book', $books[1]->slug);
+    }
+
+    public function test_update()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        // Cheeky initial update to refresh slug
+        $this->asEditor()->put($book->getUrl(), ['name' => $book->name . '5', 'description' => $book->description]);
+        $book->refresh();
+
+        $newName = $book->name . ' Updated';
+        $newDesc = $book->description . ' with more content';
+
+        $resp = $this->get($book->getUrl('/edit'));
+        $resp->assertSee($book->name);
+        $resp->assertSee($book->description);
+        $resp->assertElementContains('form[action="' . $book->getUrl() . '"]', 'Save Book');
+
+        $resp = $this->put($book->getUrl(), ['name' => $newName, 'description' => $newDesc]);
+        $resp->assertRedirect($book->getUrl() . '-updated');
+
+        $resp = $this->get($book->getUrl() . '-updated');
+        $resp->assertSee($newName);
+        $resp->assertSee($newDesc);
+    }
+
+    public function test_delete()
     {
         $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
         $this->assertNull($book->deleted_at);
@@ -31,4 +100,197 @@ class BookTest extends TestCase
         $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
         $redirectReq->assertNotificationContains('Book Successfully Deleted');
     }
-}
\ No newline at end of file
+
+    public function test_cancel_on_create_page_leads_back_to_books_listing()
+    {
+        $resp = $this->asEditor()->get('/create-book');
+        $resp->assertElementContains('form a[href="' . url('/books') . '"]', 'Cancel');
+    }
+
+    public function test_cancel_on_edit_book_page_leads_back_to_book()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/edit'));
+        $resp->assertElementContains('form a[href="' . $book->getUrl() . '"]', 'Cancel');
+    }
+
+    public function test_next_previous_navigation_controls_show_within_book_content()
+    {
+        $book = Book::query()->first();
+        $chapter = $book->chapters->first();
+
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $resp->assertElementContains('#sibling-navigation', 'Next');
+        $resp->assertElementContains('#sibling-navigation', substr($chapter->pages[0]->name, 0, 20));
+
+        $resp = $this->get($chapter->pages[0]->getUrl());
+        $resp->assertElementContains('#sibling-navigation', substr($chapter->pages[1]->name, 0, 20));
+        $resp->assertElementContains('#sibling-navigation', 'Previous');
+        $resp->assertElementContains('#sibling-navigation', substr($chapter->name, 0, 20));
+    }
+
+    public function test_recently_viewed_books_updates_as_expected()
+    {
+        $books = Book::all()->take(2);
+
+        $this->asAdmin()->get('/books')
+            ->assertElementNotContains('#recents', $books[0]->name)
+            ->assertElementNotContains('#recents', $books[1]->name);
+
+        $this->get($books[0]->getUrl());
+        $this->get($books[1]->getUrl());
+
+        $this->get('/books')
+            ->assertElementContains('#recents', $books[0]->name)
+            ->assertElementContains('#recents', $books[1]->name);
+    }
+
+    public function test_popular_books_updates_upon_visits()
+    {
+        $books = Book::all()->take(2);
+
+        $this->asAdmin()->get('/books')
+            ->assertElementNotContains('#popular', $books[0]->name)
+            ->assertElementNotContains('#popular', $books[1]->name);
+
+        $this->get($books[0]->getUrl());
+        $this->get($books[1]->getUrl());
+        $this->get($books[0]->getUrl());
+
+        $this->get('/books')
+            ->assertElementContains('#popular .book:nth-child(1)', $books[0]->name)
+            ->assertElementContains('#popular .book:nth-child(2)', $books[1]->name);
+    }
+
+    public function test_books_view_shows_view_toggle_option()
+    {
+        /** @var Book $book */
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'books_view_type', 'list');
+
+        $resp = $this->actingAs($editor)->get('/books');
+        $resp->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'Grid View');
+        $resp->assertElementExists('input[name="view_type"][value="grid"]');
+
+        $resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'grid']);
+        $resp->assertRedirect();
+        $this->assertEquals('grid', setting()->getUser($editor, 'books_view_type'));
+
+        $resp = $this->actingAs($editor)->get('/books');
+        $resp->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'List View');
+        $resp->assertElementExists('input[name="view_type"][value="list"]');
+
+        $resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'list']);
+        $resp->assertRedirect();
+        $this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
+    }
+
+    public function test_slug_multi_byte_url_safe()
+    {
+        $book = $this->newBook([
+            'name' => 'информация',
+        ]);
+
+        $this->assertEquals('informaciya', $book->slug);
+
+        $book = $this->newBook([
+            'name' => '¿Qué?',
+        ]);
+
+        $this->assertEquals('que', $book->slug);
+    }
+
+    public function test_slug_format()
+    {
+        $book = $this->newBook([
+            'name' => 'PartA / PartB / PartC',
+        ]);
+
+        $this->assertEquals('parta-partb-partc', $book->slug);
+    }
+
+    public function test_show_view_has_copy_button()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $resp = $this->asEditor()->get($book->getUrl());
+
+        $resp->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy');
+    }
+
+    public function test_copy_view()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/copy'));
+
+        $resp->assertOk();
+        $resp->assertSee('Copy Book');
+        $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->getDirectChildren()->count(), $copy->getDirectChildren()->count());
+    }
+
+    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->getDirectChildren() as $child) {
+            $child->restricted = true;
+            $child->save();
+            $this->regenEntityPermissions($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->getDirectChildren()->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->getViewer();
+        $this->giveUserPermissions($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()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookRepo = $this->app->make(BookRepo::class);
+        $coverImageFile = $this->getTestImage('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);
+    }
+}
index e9350a32be1636a39ddeb7c5d374badd8a4f1724..f099ca2bbcbb3197a1bf409caba3b512e60857b9 100644 (file)
@@ -1,11 +1,38 @@
-<?php namespace Tests\Entity;
+<?php
 
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class ChapterTest extends TestCase
 {
-    public function test_chapter_delete()
+    public function test_create()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $chapter = Chapter::factory()->make([
+            'name' => 'My First Chapter',
+        ]);
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertElementContains('a[href="' . $book->getUrl('/create-chapter') . '"]', 'New Chapter');
+
+        $resp = $this->get($book->getUrl('/create-chapter'));
+        $resp->assertElementContains('form[action="' . $book->getUrl('/create-chapter') . '"][method="POST"]', 'Save Chapter');
+
+        $resp = $this->post($book->getUrl('/create-chapter'), $chapter->only('name', 'description'));
+        $resp->assertRedirect($book->getUrl('/chapter/my-first-chapter'));
+
+        $resp = $this->get($book->getUrl('/chapter/my-first-chapter'));
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($chapter->description);
+    }
+
+    public function test_delete()
     {
         $chapter = Chapter::query()->whereHas('pages')->first();
         $this->assertNull($chapter->deleted_at);
@@ -28,4 +55,95 @@ class ChapterTest extends TestCase
         $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
         $redirectReq->assertNotificationContains('Chapter Successfully Deleted');
     }
-}
\ No newline at end of file
+
+    public function test_show_view_has_copy_button()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $resp->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy');
+    }
+
+    public function test_copy_view()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+
+        $resp = $this->asEditor()->get($chapter->getUrl('/copy'));
+        $resp->assertOk();
+        $resp->assertSee('Copy Chapter');
+        $resp->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]");
+        $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()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->whereHas('pages')->first();
+
+        // Hide pages to all non-admin roles
+        /** @var Page $page */
+        foreach ($chapter->pages as $page) {
+            $page->restricted = true;
+            $page->save();
+            $this->regenEntityPermissions($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()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->whereHas('pages')->first();
+        $viewer = $this->getViewer();
+        $this->giveUserPermissions($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->giveUserPermissions($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());
+    }
+}
index 49ceede9f3edd23207b5f62906c14fe677fcd043..23607f5a70f423b182946a06db0d2f3b4fee3778 100644 (file)
@@ -1,35 +1,35 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Models\Page;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class CommentSettingTest extends BrowserKitTest
+class CommentSettingTest extends TestCase
 {
     protected $page;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
-        $this->page = Page::first();
+        $this->page = Page::query()->first();
     }
 
     public function test_comment_disable()
     {
-        $this->asAdmin();
-
         $this->setSettings(['app-disable-comments' => 'true']);
+        $this->asAdmin();
 
-        $this->asAdmin()->visit($this->page->getUrl())
-            ->pageNotHasElement('.comments-list');
+        $this->asAdmin()->get($this->page->getUrl())
+            ->assertElementNotExists('.comments-list');
     }
 
     public function test_comment_enable()
     {
-        $this->asAdmin();
-
         $this->setSettings(['app-disable-comments' => 'false']);
+        $this->asAdmin();
 
-        $this->asAdmin()->visit($this->page->getUrl())
-            ->pageHasElement('.comments-list');
+        $this->asAdmin()->get($this->page->getUrl())
+            ->assertElementExists('.comments-list');
     }
-}
\ No newline at end of file
+}
index 63d1a29a29ac656bf5d53ec38a00fad8102af35c..1e8ecbcac2cad0a1bda62c38303e2575df3ae775 100644 (file)
@@ -1,18 +1,19 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
-use BookStack\Entities\Models\Page;
 use BookStack\Actions\Comment;
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class CommentTest extends TestCase
 {
-
     public function test_add_comment()
     {
         $this->asAdmin();
         $page = Page::first();
 
-        $comment = factory(Comment::class)->make(['parent_id' => 2]);
+        $comment = Comment::factory()->make(['parent_id' => 2]);
         $resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $resp->assertStatus(200);
@@ -22,11 +23,11 @@ class CommentTest extends TestCase
         $pageResp->assertSee($comment->text);
 
         $this->assertDatabaseHas('comments', [
-            'local_id' => 1,
-            'entity_id' => $page->id,
+            'local_id'    => 1,
+            'entity_id'   => $page->id,
             'entity_type' => Page::newModelInstance()->getMorphClass(),
-            'text' => $comment->text,
-            'parent_id' => 2
+            'text'        => $comment->text,
+            'parent_id'   => 2,
         ]);
     }
 
@@ -35,7 +36,7 @@ class CommentTest extends TestCase
         $this->asAdmin();
         $page = Page::first();
 
-        $comment = factory(Comment::class)->make();
+        $comment = Comment::factory()->make();
         $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
@@ -49,8 +50,8 @@ class CommentTest extends TestCase
         $resp->assertDontSee($comment->text);
 
         $this->assertDatabaseHas('comments', [
-            'text' => $newText,
-            'entity_id' => $page->id
+            'text'      => $newText,
+            'entity_id' => $page->id,
         ]);
     }
 
@@ -59,7 +60,7 @@ class CommentTest extends TestCase
         $this->asAdmin();
         $page = Page::first();
 
-        $comment = factory(Comment::class)->make();
+        $comment = Comment::factory()->make();
         $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
@@ -68,7 +69,7 @@ class CommentTest extends TestCase
         $resp->assertStatus(200);
 
         $this->assertDatabaseMissing('comments', [
-            'id' => $comment->id
+            'id' => $comment->id,
         ]);
     }
 
@@ -80,14 +81,14 @@ class CommentTest extends TestCase
         ]);
 
         $this->assertDatabaseHas('comments', [
-            'entity_id' => $page->id,
+            'entity_id'   => $page->id,
             'entity_type' => $page->getMorphClass(),
-            'text' => '# My Title',
-            'html' => "<h1>My Title</h1>\n",
+            'text'        => '# My Title',
+            'html'        => "<h1>My Title</h1>\n",
         ]);
 
         $pageView = $this->get($page->getUrl());
-        $pageView->assertSee('<h1>My Title</h1>');
+        $pageView->assertSee('<h1>My Title</h1>', false);
     }
 
     public function test_html_cannot_be_injected_via_comment_content()
@@ -101,7 +102,7 @@ class CommentTest extends TestCase
         ]);
 
         $pageView = $this->get($page->getUrl());
-        $pageView->assertDontSee($script);
+        $pageView->assertDontSee($script, false);
         $pageView->assertSee('sometextinthecomment');
 
         $comment = $page->comments()->first();
@@ -110,7 +111,7 @@ class CommentTest extends TestCase
         ]);
 
         $pageView = $this->get($page->getUrl());
-        $pageView->assertDontSee($script);
+        $pageView->assertDontSee($script, false);
         $pageView->assertSee('sometextinthecommentupdated');
     }
 }
diff --git a/tests/Entity/EntityAccessTest.php b/tests/Entity/EntityAccessTest.php
new file mode 100644 (file)
index 0000000..f2f2445
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Auth\UserRepo;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Repos\PageRepo;
+use Tests\TestCase;
+
+class EntityAccessTest extends TestCase
+{
+    public function test_entities_viewable_after_creator_deletion()
+    {
+        // Create required assets and revisions
+        $creator = $this->getEditor();
+        $updater = $this->getViewer();
+        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
+        app()->make(UserRepo::class)->destroy($creator);
+        app()->make(PageRepo::class)->update($entities['page'], ['html' => '<p>hello!</p>>']);
+
+        $this->checkEntitiesViewable($entities);
+    }
+
+    public function test_entities_viewable_after_updater_deletion()
+    {
+        // Create required assets and revisions
+        $creator = $this->getViewer();
+        $updater = $this->getEditor();
+        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
+        app()->make(UserRepo::class)->destroy($updater);
+        app()->make(PageRepo::class)->update($entities['page'], ['html' => '<p>Hello there!</p>']);
+
+        $this->checkEntitiesViewable($entities);
+    }
+
+    /**
+     * @param array<string, Entity> $entities
+     */
+    private function checkEntitiesViewable(array $entities)
+    {
+        // Check pages and books are visible.
+        $this->asAdmin();
+        foreach ($entities as $entity) {
+            $this->get($entity->getUrl())
+                ->assertStatus(200)
+                ->assertSee($entity->name);
+        }
+
+        // Check revision listing shows no errors.
+        $this->get($entities['page']->getUrl('/revisions'))->assertStatus(200);
+    }
+}
index 6b53b2cb61705cec2391217737ee83ee6a475a10..ab5777e98981e9ec9c7e77db546b48b47ec07c47 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Actions\Tag;
 use BookStack\Entities\Models\Book;
@@ -9,7 +11,6 @@ use Tests\TestCase;
 
 class EntitySearchTest extends TestCase
 {
-
     public function test_page_search()
     {
         $book = Book::all()->first();
@@ -17,15 +18,17 @@ class EntitySearchTest extends TestCase
 
         $search = $this->asEditor()->get('/search?term=' . urlencode($page->name));
         $search->assertSee('Search Results');
-        $search->assertSee($page->name);
+        $search->assertSeeText($page->name, true);
     }
 
     public function test_bookshelf_search()
     {
-        $shelf = Bookshelf::first();
-        $search = $this->asEditor()->get('/search?term=' . urlencode(mb_substr($shelf->name, 0, 3)) . '  {type:bookshelf}');
-        $search->assertStatus(200);
-        $search->assertSee($shelf->name);
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+
+        $search = $this->asEditor()->get('/search?term=' . urlencode($shelf->name) . '  {type:bookshelf}');
+        $search->assertSee('Search Results');
+        $search->assertSeeText($shelf->name, true);
     }
 
     public function test_invalid_page_search()
@@ -80,13 +83,13 @@ class EntitySearchTest extends TestCase
     {
         $newTags = [
             new Tag([
-                'name' => 'animal',
-                'value' => 'cat'
+                'name'  => 'animal',
+                'value' => 'cat',
             ]),
             new Tag([
-                'name' => 'color',
-                'value' => 'red'
-            ])
+                'name'  => 'color',
+                'value' => 'red',
+            ]),
         ];
 
         $pageA = Page::first();
@@ -117,6 +120,18 @@ class EntitySearchTest extends TestCase
         $exactSearchB->assertStatus(200)->assertDontSee($page->name);
     }
 
+    public function test_search_terms_with_delimiters_are_converted_to_exact_matches()
+    {
+        $this->asEditor();
+        $page = $this->newPage(['name' => 'Delimiter test', 'html' => '<p>1.1 2,2 3?3 4:4 5;5 (8) &lt;9&gt; "10" \'11\' `12`</p>']);
+        $terms = explode(' ', '1.1 2,2 3?3 4:4 5;5 (8) <9> "10" \'11\' `12`');
+
+        foreach ($terms as $term) {
+            $search = $this->get('/search?term=' . urlencode($term));
+            $search->assertSee($page->name);
+        }
+    }
+
     public function test_search_filters()
     {
         $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']);
@@ -135,22 +150,22 @@ class EntitySearchTest extends TestCase
         $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertDontSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorSlug.'}'))->assertDontSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:' . $editorSlug . '}'))->assertDontSee($page->name);
         $page->created_by = $editorId;
         $page->save();
         $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {created_by: '.$editorSlug.'}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {created_by: ' . $editorSlug . '}'))->assertSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name);
         $page->updated_by = $editorId;
         $page->save();
         $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorSlug.'}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:' . $editorSlug . '}'))->assertSee($page->name);
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name);
         $page->owned_by = $editorId;
         $page->save();
         $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertSee($page->name);
-        $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:'.$editorSlug.'}'))->assertSee($page->name);
+        $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:' . $editorSlug . '}'))->assertSee($page->name);
 
         // Content filters
         $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name);
@@ -301,4 +316,117 @@ class EntitySearchTest extends TestCase
         $search->assertSeeText($page->name);
         $search->assertSee($page->getUrl());
     }
+
+    public function test_search_ranks_common_words_lower()
+    {
+        $this->newPage(['name' => 'Test page A', 'html' => '<p>dog biscuit dog dog</p>']);
+        $this->newPage(['name' => 'Test page B', 'html' => '<p>cat biscuit</p>']);
+
+        $search = $this->asEditor()->get('/search?term=cat+dog+biscuit');
+        $search->assertElementContains('.entity-list > .page', 'Test page A', 1);
+        $search->assertElementContains('.entity-list > .page', 'Test page B', 2);
+
+        for ($i = 0; $i < 2; $i++) {
+            $this->newPage(['name' => 'Test page ' . $i, 'html' => '<p>dog</p>']);
+        }
+
+        $search = $this->asEditor()->get('/search?term=cat+dog+biscuit');
+        $search->assertElementContains('.entity-list > .page', 'Test page B', 1);
+        $search->assertElementContains('.entity-list > .page', 'Test page A', 2);
+    }
+
+    public function test_terms_in_headers_have_an_adjusted_index_score()
+    {
+        $page = $this->newPage(['name' => 'Test page A', 'html' => '
+            <p>TermA</p>
+            <h1>TermB <strong>TermNested</strong></h1>
+            <h2>TermC</h2>
+            <h3>TermD</h3>
+            <h4>TermE</h4>
+            <h5>TermF</h5>
+            <h6>TermG</h6>
+        ']);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+
+        $this->assertEquals(1, $scoreByTerm->get('TermA'));
+        $this->assertEquals(10, $scoreByTerm->get('TermB'));
+        $this->assertEquals(10, $scoreByTerm->get('TermNested'));
+        $this->assertEquals(5, $scoreByTerm->get('TermC'));
+        $this->assertEquals(4, $scoreByTerm->get('TermD'));
+        $this->assertEquals(3, $scoreByTerm->get('TermE'));
+        $this->assertEquals(2, $scoreByTerm->get('TermF'));
+        // Is 1.5 but stored as integer, rounding up
+        $this->assertEquals(2, $scoreByTerm->get('TermG'));
+    }
+
+    public function test_name_and_content_terms_are_merged_to_single_score()
+    {
+        $page = $this->newPage(['name' => 'TermA', 'html' => '
+            <p>TermA</p>
+        ']);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+
+        // Scores 40 for being in the name then 1 for being in the content
+        $this->assertEquals(41, $scoreByTerm->get('TermA'));
+    }
+
+    public function test_tag_names_and_values_are_indexed_for_search()
+    {
+        $page = $this->newPage(['name' => 'PageA', 'html' => '<p>content</p>', 'tags' => [
+            ['name' => 'Animal', 'value' => 'MeowieCat'],
+            ['name' => 'SuperImportant'],
+        ]]);
+
+        $scoreByTerm = $page->searchTerms()->pluck('score', 'term');
+        $this->assertEquals(5, $scoreByTerm->get('MeowieCat'));
+        $this->assertEquals(3, $scoreByTerm->get('Animal'));
+        $this->assertEquals(3, $scoreByTerm->get('SuperImportant'));
+    }
+
+    public function test_matching_terms_in_search_results_are_highlighted()
+    {
+        $this->newPage(['name' => 'My Meowie Cat', 'html' => '<p>A superimportant page about meowieable animals</p>', 'tags' => [
+            ['name' => 'Animal', 'value' => 'MeowieCat'],
+            ['name' => 'SuperImportant'],
+        ]]);
+
+        $search = $this->asEditor()->get('/search?term=SuperImportant+Meowie');
+        // Title
+        $search->assertSee('My <strong>Meowie</strong> Cat', false);
+        // Content
+        $search->assertSee('A <strong>superimportant</strong> page about <strong>meowie</strong>able animals', false);
+        // Tag name
+        $search->assertElementContains('.tag-name.highlight', 'SuperImportant');
+        // Tag value
+        $search->assertElementContains('.tag-value.highlight', 'MeowieCat');
+    }
+
+    public function test_match_highlighting_works_with_multibyte_content()
+    {
+        $this->newPage([
+            'name' => 'Test Page',
+            'html' => '<p>На мен ми трябва нещо добро test</p>',
+        ]);
+
+        $search = $this->asEditor()->get('/search?term=' . urlencode('На мен ми трябва нещо добро'));
+        $search->assertSee('<strong>На</strong> <strong>мен</strong> <strong>ми</strong> <strong>трябва</strong> <strong>нещо</strong> <strong>добро</strong> test', false);
+    }
+
+    public function test_html_entities_in_item_details_remains_escaped_in_search_results()
+    {
+        $this->newPage(['name' => 'My <cool> TestPageContent', 'html' => '<p>My supercool &lt;great&gt; TestPageContent page</p>']);
+
+        $search = $this->asEditor()->get('/search?term=TestPageContent');
+        $search->assertSee('My &lt;cool&gt; <strong>TestPageContent</strong>', false);
+        $search->assertSee('My supercool &lt;great&gt; <strong>TestPageContent</strong> page', false);
+    }
+
+    public function test_searches_with_user_filters_adds_them_into_advanced_search_form()
+    {
+        $resp = $this->asEditor()->get('/search?term=' . urlencode('test {updated_by:me} {created_by:dan}'));
+        $resp->assertElementExists('form input[type="hidden"][name="filters[updated_by]"][value="me"]');
+        $resp->assertElementExists('form input[type="hidden"][name="filters[created_by]"][value="dan"]');
+    }
 }
diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php
deleted file mode 100644 (file)
index 52f9a3a..0000000
+++ /dev/null
@@ -1,317 +0,0 @@
-<?php namespace Tests\Entity;
-
-use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Chapter;
-use BookStack\Entities\Models\Page;
-use BookStack\Auth\UserRepo;
-use BookStack\Entities\Repos\PageRepo;
-use Carbon\Carbon;
-use Tests\BrowserKitTest;
-
-class EntityTest extends BrowserKitTest
-{
-
-    public function test_entity_creation()
-    {
-        // Test Creation
-        $book = $this->bookCreation();
-        $chapter = $this->chapterCreation($book);
-        $this->pageCreation($chapter);
-
-        // Test Updating
-        $this->bookUpdate($book);
-    }
-
-    public function bookUpdate(Book $book)
-    {
-        $newName = $book->name . ' Updated';
-        $this->asAdmin()
-            // Go to edit screen
-            ->visit($book->getUrl() . '/edit')
-            ->see($book->name)
-            // Submit new name
-            ->type($newName, '#name')
-            ->press('Save Book')
-            // Check page url and text
-            ->seePageIs($book->getUrl() . '-updated')
-            ->see($newName);
-
-        return Book::find($book->id);
-    }
-
-    public function test_book_sort_page_shows()
-    {
-        $books =  Book::all();
-        $bookToSort = $books[0];
-        $this->asAdmin()
-            ->visit($bookToSort->getUrl())
-            ->click('Sort')
-            ->seePageIs($bookToSort->getUrl() . '/sort')
-            ->seeStatusCode(200)
-            ->see($bookToSort->name);
-    }
-
-    public function test_book_sort_item_returns_book_content()
-    {
-        $books =  Book::all();
-        $bookToSort = $books[0];
-        $firstPage = $bookToSort->pages[0];
-        $firstChapter = $bookToSort->chapters[0];
-        $this->asAdmin()
-            ->visit($bookToSort->getUrl() . '/sort-item')
-            // Ensure book details are returned
-            ->see($bookToSort->name)
-            ->see($firstPage->name)
-            ->see($firstChapter->name);
-    }
-
-    public function test_toggle_book_view()
-    {
-        $editor = $this->getEditor();
-        setting()->putUser($editor, 'books_view_type', 'grid');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->pageHasElement('.featured-image-container')
-            ->submitForm('List View')
-            // Check redirection.
-            ->seePageIs('/books')
-            ->pageNotHasElement('.featured-image-container');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->submitForm('Grid View')
-            ->seePageIs('/books')
-            ->pageHasElement('.featured-image-container');
-
-    }
-
-    public function pageCreation($chapter)
-    {
-        $page = factory(Page::class)->make([
-            'name' => 'My First Page'
-        ]);
-
-        $this->asAdmin()
-            // Navigate to page create form
-            ->visit($chapter->getUrl())
-            ->click('New Page');
-
-        $draftPage = Page::where('draft', '=', true)->orderBy('created_at', 'desc')->first();
-
-        $this->seePageIs($draftPage->getUrl())
-            // Fill out form
-            ->type($page->name, '#name')
-            ->type($page->html, '#html')
-            ->press('Save Page')
-            // Check redirect and page
-            ->seePageIs($chapter->book->getUrl() . '/page/my-first-page')
-            ->see($page->name);
-
-        $page = Page::where('slug', '=', 'my-first-page')->where('chapter_id', '=', $chapter->id)->first();
-        return $page;
-    }
-
-    public function chapterCreation(Book $book)
-    {
-        $chapter = factory(Chapter::class)->make([
-            'name' => 'My First Chapter'
-        ]);
-
-        $this->asAdmin()
-            // Navigate to chapter create page
-            ->visit($book->getUrl())
-            ->click('New Chapter')
-            ->seePageIs($book->getUrl() . '/create-chapter')
-            // Fill out form
-            ->type($chapter->name, '#name')
-            ->type($chapter->description, '#description')
-            ->press('Save Chapter')
-            // Check redirect and landing page
-            ->seePageIs($book->getUrl() . '/chapter/my-first-chapter')
-            ->see($chapter->name)->see($chapter->description);
-
-        $chapter = Chapter::where('slug', '=', 'my-first-chapter')->where('book_id', '=', $book->id)->first();
-        return $chapter;
-    }
-
-    public function bookCreation()
-    {
-        $book = factory(Book::class)->make([
-            'name' => 'My First Book'
-        ]);
-        $this->asAdmin()
-            ->visit('/books')
-            // Choose to create a book
-            ->click('Create New Book')
-            ->seePageIs('/create-book')
-            // Fill out form & save
-            ->type($book->name, '#name')
-            ->type($book->description, '#description')
-            ->press('Save Book')
-            // Check it redirects correctly
-            ->seePageIs('/books/my-first-book')
-            ->see($book->name)->see($book->description);
-
-        // Ensure duplicate names are given different slugs
-        $this->asAdmin()
-            ->visit('/create-book')
-            ->type($book->name, '#name')
-            ->type($book->description, '#description')
-            ->press('Save Book');
-
-        $expectedPattern = '/\/books\/my-first-book-[0-9a-zA-Z]{3}/';
-        $this->assertMatchesRegularExpression($expectedPattern, $this->currentUri, "Did not land on expected page [$expectedPattern].\n");
-
-        $book = Book::where('slug', '=', 'my-first-book')->first();
-        return $book;
-    }
-
-    public function test_entities_viewable_after_creator_deletion()
-    {
-        // Create required assets and revisions
-        $creator = $this->getEditor();
-        $updater = $this->getEditor();
-        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
-        $this->actingAs($creator);
-        app(UserRepo::class)->destroy($creator);
-        app(PageRepo::class)->update($entities['page'], ['html' => '<p>hello!</p>>']);
-
-        $this->checkEntitiesViewable($entities);
-    }
-
-    public function test_entities_viewable_after_updater_deletion()
-    {
-        // Create required assets and revisions
-        $creator = $this->getEditor();
-        $updater = $this->getEditor();
-        $entities = $this->createEntityChainBelongingToUser($creator, $updater);
-        $this->actingAs($updater);
-        app(UserRepo::class)->destroy($updater);
-        app(PageRepo::class)->update($entities['page'], ['html' => '<p>Hello there!</p>']);
-
-        $this->checkEntitiesViewable($entities);
-    }
-
-    private function checkEntitiesViewable($entities)
-    {
-        // Check pages and books are visible.
-        $this->asAdmin();
-        $this->visit($entities['book']->getUrl())->seeStatusCode(200)
-            ->visit($entities['chapter']->getUrl())->seeStatusCode(200)
-            ->visit($entities['page']->getUrl())->seeStatusCode(200);
-        // Check revision listing shows no errors.
-        $this->visit($entities['page']->getUrl())
-            ->click('Revisions')->seeStatusCode(200);
-    }
-
-    public function test_recently_updated_pages_view()
-    {
-        $user = $this->getEditor();
-        $content = $this->createEntityChainBelongingToUser($user);
-
-        $this->asAdmin()->visit('/pages/recently-updated')
-            ->seeInNthElement('.entity-list .page', 0, $content['page']->name);
-    }
-
-    public function test_old_page_slugs_redirect_to_new_pages()
-    {
-        $page = Page::first();
-        $pageUrl = $page->getUrl();
-        $newPageUrl = '/books/' . $page->book->slug . '/page/super-test-page';
-        // Need to save twice since revisions are not generated in seeder.
-        $this->asAdmin()->visit($pageUrl)
-            ->clickInElement('#content', 'Edit')
-            ->type('super test', '#name')
-            ->press('Save Page');
-
-        $page = Page::first();
-        $pageUrl = $page->getUrl();
-
-        // Second Save
-        $this->visit($pageUrl)
-            ->clickInElement('#content', 'Edit')
-            ->type('super test page', '#name')
-            ->press('Save Page')
-            // Check redirect
-            ->seePageIs($newPageUrl);
-
-        $this->visit($pageUrl)
-            ->seePageIs($newPageUrl);
-    }
-
-    public function test_recently_updated_pages_on_home()
-    {
-        $page = Page::orderBy('updated_at', 'asc')->first();
-        Page::where('id', '!=', $page->id)->update([
-            'updated_at' => Carbon::now()->subSecond(1)
-        ]);
-        $this->asAdmin()->visit('/')
-            ->dontSeeInElement('#recently-updated-pages', $page->name);
-        $this->visit($page->getUrl() . '/edit')
-            ->press('Save Page')
-            ->visit('/')
-            ->seeInElement('#recently-updated-pages', $page->name);
-    }
-
-    public function test_slug_multi_byte_url_safe()
-    {
-        $book = $this->newBook([
-            'name' => 'информация'
-        ]);
-
-        $this->assertEquals('informatsiya', $book->slug);
-
-        $book = $this->newBook([
-            'name' => '¿Qué?'
-        ]);
-
-        $this->assertEquals('que', $book->slug);
-    }
-
-    public function test_slug_format()
-    {
-        $book = $this->newBook([
-            'name' => 'PartA / PartB / PartC'
-        ]);
-
-        $this->assertEquals('parta-partb-partc', $book->slug);
-    }
-
-    public function test_shelf_cancel_creation_returns_to_correct_place()
-    {
-        $shelf = Bookshelf::first();
-
-        // Cancel button from shelf goes back to shelf
-        $this->asEditor()->visit($shelf->getUrl('/create-book'))
-            ->see('Cancel')
-            ->click('Cancel')
-            ->seePageIs($shelf->getUrl());
-
-        // Cancel button from books goes back to books
-        $this->asEditor()->visit('/create-book')
-            ->see('Cancel')
-            ->click('Cancel')
-            ->seePageIs('/books');
-
-        // Cancel button from book edit goes back to book
-        $book = Book::first();
-
-        $this->asEditor()->visit($book->getUrl('/edit'))
-            ->see('Cancel')
-            ->click('Cancel')
-            ->seePageIs($book->getUrl());
-    }
-
-    public function test_page_within_chapter_deletion_returns_to_chapter()
-    {
-        $chapter = Chapter::query()->first();
-        $page = $chapter->pages()->first();
-
-        $this->asEditor()->visit($page->getUrl('/delete'))
-            ->submitForm('Confirm')
-            ->seePageIs($chapter->getUrl());
-    }
-
-}
index d04ccc69a85304184e5c29293ec4644dcf6acf97..fc6b74088c0199e7976ff1b54a1712f81aa809f8 100644 (file)
@@ -1,18 +1,21 @@
-<?php namespace Tests\Entity;
+<?php
 
+namespace Tests\Entity;
+
+use BookStack\Auth\Role;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\PdfGenerator;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class ExportTest extends TestCase
 {
-
     public function test_page_text_export()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asEditor();
 
         $resp = $this->get($page->getUrl('/export/plaintext'));
@@ -23,7 +26,7 @@ class ExportTest extends TestCase
 
     public function test_page_pdf_export()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asEditor();
 
         $resp = $this->get($page->getUrl('/export/pdf'));
@@ -33,7 +36,7 @@ class ExportTest extends TestCase
 
     public function test_page_html_export()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asEditor();
 
         $resp = $this->get($page->getUrl('/export/html'));
@@ -44,7 +47,7 @@ class ExportTest extends TestCase
 
     public function test_book_text_export()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $book = $page->book;
         $this->asEditor();
 
@@ -57,7 +60,7 @@ class ExportTest extends TestCase
 
     public function test_book_pdf_export()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $book = $page->book;
         $this->asEditor();
 
@@ -68,7 +71,7 @@ class ExportTest extends TestCase
 
     public function test_book_html_export()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $book = $page->book;
         $this->asEditor();
 
@@ -95,7 +98,7 @@ class ExportTest extends TestCase
 
     public function test_chapter_text_export()
     {
-        $chapter = Chapter::first();
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages[0];
         $this->asEditor();
 
@@ -108,7 +111,7 @@ class ExportTest extends TestCase
 
     public function test_chapter_pdf_export()
     {
-        $chapter = Chapter::first();
+        $chapter = Chapter::query()->first();
         $this->asEditor();
 
         $resp = $this->get($chapter->getUrl('/export/pdf'));
@@ -118,7 +121,7 @@ class ExportTest extends TestCase
 
     public function test_chapter_html_export()
     {
-        $chapter = Chapter::first();
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages[0];
         $this->asEditor();
 
@@ -131,29 +134,41 @@ class ExportTest extends TestCase
 
     public function test_page_html_export_contains_custom_head_if_set()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
 
-        $customHeadContent = "<style>p{color: red;}</style>";
+        $customHeadContent = '<style>p{color: red;}</style>';
         $this->setSettings(['app-custom-head' => $customHeadContent]);
 
         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($customHeadContent);
+        $resp->assertSee($customHeadContent, false);
+    }
+
+    public function test_page_html_export_does_not_break_with_only_comments_in_custom_head()
+    {
+        $page = Page::query()->first();
+
+        $customHeadContent = '<!-- A comment -->';
+        $this->setSettings(['app-custom-head' => $customHeadContent]);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertSee($customHeadContent, false);
     }
 
     public function test_page_html_export_use_absolute_dates()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
 
         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
-        $resp->assertSee($page->created_at->toDayDateTimeString());
+        $resp->assertSee($page->created_at->formatLocalized('%e %B %Y %H:%M:%S'));
         $resp->assertDontSee($page->created_at->diffForHumans());
-        $resp->assertSee($page->updated_at->toDayDateTimeString());
+        $resp->assertSee($page->updated_at->formatLocalized('%e %B %Y %H:%M:%S'));
         $resp->assertDontSee($page->updated_at->diffForHumans());
     }
 
     public function test_page_export_does_not_include_user_or_revision_links()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
 
         $resp = $this->asEditor()->get($page->getUrl('/export/html'));
         $resp->assertDontSee($page->getUrl('/revisions'));
@@ -163,7 +178,7 @@ class ExportTest extends TestCase
 
     public function test_page_export_sets_right_data_type_for_svg_embeds()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         Storage::disk('local')->makeDirectory('uploads/images/gallery');
         Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
         $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
@@ -174,12 +189,12 @@ class ExportTest extends TestCase
         Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
 
         $resp->assertStatus(200);
-        $resp->assertSee('<img src="data:image/svg+xml;base64');
+        $resp->assertSee('<img src="data:image/svg+xml;base64', false);
     }
 
     public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         Storage::disk('local')->makeDirectory('uploads/images/gallery');
         Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
         Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
@@ -195,10 +210,10 @@ class ExportTest extends TestCase
 
     public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
-            .'<img src="http://localhost/uploads/svg_test.svg"/>'
-            .'<img src="/uploads/svg_test.svg"/>';
+            . '<img src="http://localhost/uploads/svg_test.svg"/>'
+            . '<img src="/uploads/svg_test.svg"/>';
         $storageDisk = Storage::disk('local');
         $storageDisk->makeDirectory('uploads/images/gallery');
         $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
@@ -210,9 +225,37 @@ class ExportTest extends TestCase
         $storageDisk->delete('uploads/images/gallery/svg_test.svg');
         $storageDisk->delete('uploads/svg_test.svg');
 
-        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg');
+        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false);
         $resp->assertSee('http://localhost/uploads/svg_test.svg');
-        $resp->assertSee('src="/uploads/svg_test.svg"');
+        $resp->assertSee('src="/uploads/svg_test.svg"', false);
+    }
+
+    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local()
+    {
+        $contents = file_get_contents(public_path('.htaccess'));
+        config()->set('filesystems.images', 'local');
+
+        $page = Page::query()->first();
+        $page->html = '<img src="http://localhost/uploads/images/../../.htaccess"/>';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee(base64_encode($contents));
+    }
+
+    public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure()
+    {
+        $testFilePath = storage_path('logs/test.txt');
+        config()->set('filesystems.images', 'local_secure');
+        file_put_contents($testFilePath, 'I am a cat');
+
+        $page = Page::query()->first();
+        $page->html = '<img src="http://localhost/uploads/images/../../logs/test.txt"/>';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee(base64_encode('I am a cat'));
+        unlink($testFilePath);
     }
 
     public function test_exports_removes_scripts_from_custom_head()
@@ -230,4 +273,161 @@ class ExportTest extends TestCase
         }
     }
 
+    public function test_page_export_with_deleted_creator_and_updater()
+    {
+        $user = $this->getViewer(['name' => 'ExportWizardTheFifth']);
+        $page = Page::query()->first();
+        $page->created_by = $user->id;
+        $page->updated_by = $user->id;
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertSee('ExportWizardTheFifth');
+
+        $user->delete();
+        $resp = $this->get($page->getUrl('/export/html'));
+        $resp->assertStatus(200);
+        $resp->assertDontSee('ExportWizardTheFifth');
+    }
+
+    public function test_page_pdf_export_converts_iframes_to_links()
+    {
+        $page = Page::query()->first()->forceFill([
+            'html'     => '<iframe width="560" height="315" src="//www.youtube.com/embed/ShqUjt33uOs"></iframe>',
+        ]);
+        $page->save();
+
+        $pdfHtml = '';
+        $mockPdfGenerator = $this->mock(PdfGenerator::class);
+        $mockPdfGenerator->shouldReceive('fromHtml')
+            ->with(\Mockery::capture($pdfHtml))
+            ->andReturn('');
+        $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF);
+
+        $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $this->assertStringNotContainsString('iframe>', $pdfHtml);
+        $this->assertStringContainsString('<p><a href="https://www.youtube.com/embed/ShqUjt33uOs">https://www.youtube.com/embed/ShqUjt33uOs</a></p>', $pdfHtml);
+    }
+
+    public function test_page_markdown_export()
+    {
+        $page = Page::query()->first();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
+    }
+
+    public function test_page_markdown_export_uses_existing_markdown_if_apparent()
+    {
+        $page = Page::query()->first()->forceFill([
+            'markdown' => '# A header',
+            'html'     => '<h1>Dogcat</h1>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee('A header');
+        $resp->assertDontSee('Dogcat');
+    }
+
+    public function test_page_markdown_export_converts_html_where_no_markdown()
+    {
+        $page = Page::query()->first()->forceFill([
+            'markdown' => '',
+            'html'     => '<h1>Dogcat</h1><p>Some <strong>bold</strong> text</p>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee("# Dogcat\n\nSome **bold** text");
+    }
+
+    public function test_page_markdown_export_does_not_convert_callouts()
+    {
+        $page = Page::query()->first()->forceFill([
+            'markdown' => '',
+            'html'     => '<h1>Dogcat</h1><p class="callout info">Some callout text</p><p>Another line</p>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee("# Dogcat\n\n<p class=\"callout info\">Some callout text</p>\n\nAnother line", false);
+    }
+
+    public function test_page_markdown_export_handles_bookstacks_wysiwyg_codeblock_format()
+    {
+        $page = Page::query()->first()->forceFill([
+            'markdown' => '',
+            'html'     => '<h1>Dogcat</h1>' . "\r\n" . '<pre id="bkmrk-var-a-%3D-%27cat%27%3B"><code class="language-JavaScript">var a = \'cat\';</code></pre><p>Another line</p>',
+        ]);
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/markdown'));
+        $resp->assertSee("# Dogcat\n\n```JavaScript\nvar a = 'cat';\n```\n\nAnother line", false);
+    }
+
+    public function test_chapter_markdown_export()
+    {
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+    }
+
+    public function test_book_markdown_export()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+        $resp = $this->asEditor()->get($book->getUrl('/export/markdown'));
+
+        $resp->assertSee('# ' . $book->name);
+        $resp->assertSee('# ' . $chapter->name);
+        $resp->assertSee('# ' . $page->name);
+    }
+
+    public function test_export_option_only_visible_and_accessible_with_permission()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+        $chapter = $book->chapters()->first();
+        $page = $chapter->pages()->first();
+        $entities = [$book, $chapter, $page];
+        $user = $this->getViewer();
+        $this->actingAs($user);
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertSee('/export/pdf');
+        }
+
+        /** @var Role $role */
+        $this->removePermissionFromUser($user, 'content-export');
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertDontSee('/export/pdf');
+            $resp = $this->get($entity->getUrl('/export/pdf'));
+            $this->assertPermissionError($resp);
+        }
+    }
+
+    public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        config()->set('snappy.pdf.binary', '/abc123');
+        config()->set('app.allow_untrusted_server_fetching', false);
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage.
+
+        config()->set('app.allow_untrusted_server_fetching', true);
+        $resp = $this->get($page->getUrl('/export/pdf'));
+        $resp->assertStatus(500); // Bad response indicates wkhtml usage
+    }
 }
diff --git a/tests/Entity/MarkdownTest.php b/tests/Entity/MarkdownTest.php
deleted file mode 100644 (file)
index 5e5fa8a..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php namespace Tests\Entity;
-
-use Tests\BrowserKitTest;
-
-class MarkdownTest extends BrowserKitTest
-{
-    protected $page;
-
-    public function setUp(): void
-    {
-        parent::setUp();
-        $this->page = \BookStack\Entities\Models\Page::first();
-    }
-
-    protected function setMarkdownEditor()
-    {
-        $this->setSettings(['app-editor' => 'markdown']);
-    }
-
-    public function test_default_editor_is_wysiwyg()
-    {
-        $this->assertEquals(setting('app-editor'), 'wysiwyg');
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->pageHasElement('#html-editor');
-    }
-    
-    public function test_markdown_setting_shows_markdown_editor()
-    {
-        $this->setMarkdownEditor();
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->pageNotHasElement('#html-editor')
-            ->pageHasElement('#markdown-editor');
-    }
-
-    public function test_markdown_content_given_to_editor()
-    {
-        $this->setMarkdownEditor();
-        $mdContent = '# hello. This is a test';
-        $this->page->markdown = $mdContent;
-        $this->page->save();
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->seeInField('markdown', $mdContent);
-    }
-
-    public function test_html_content_given_to_editor_if_no_markdown()
-    {
-        $this->setMarkdownEditor();
-        $this->asAdmin()->visit($this->page->getUrl() . '/edit')
-            ->seeInField('markdown', $this->page->html);
-    }
-
-}
\ No newline at end of file
index 6d5200794bc79354bd8243d80711867323f9f0c6..9524186c8f1cdc64f0b9cd43f215515948cf98b1 100644 (file)
@@ -1,16 +1,22 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
-use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\PageContent;
 use Tests\TestCase;
+use Tests\Uploads\UsesImages;
 
 class PageContentTest extends TestCase
 {
+    use UsesImages;
+
+    protected $base64Jpeg = '/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=';
 
     public function test_page_includes()
     {
-        $page = Page::first();
-        $secondPage = Page::where('id', '!=', $page->id)->first();
+        $page = Page::query()->first();
+        $secondPage = Page::query()->where('id', '!=', $page->id)->first();
 
         $secondPage->html = "<p id='section1'>Hello, This is a test</p><p id='section2'>This is a second block of content</p>";
         $secondPage->save();
@@ -38,8 +44,8 @@ class PageContentTest extends TestCase
 
     public function test_saving_page_with_includes()
     {
-        $page = Page::first();
-        $secondPage = Page::where('id', '!=', $page->id)->first();
+        $page = Page::query()->first();
+        $secondPage = Page::query()->where('id', '!=', $page->id)->first();
 
         $this->asEditor();
         $includeTag = '{{@' . $secondPage->id . '}}';
@@ -56,8 +62,10 @@ class PageContentTest extends TestCase
 
     public function test_page_includes_do_not_break_tables()
     {
-        $page = Page::first();
-        $secondPage = Page::where('id', '!=', $page->id)->first();
+        /** @var Page $page */
+        $page = Page::query()->first();
+        /** @var Page $secondPage */
+        $secondPage = Page::query()->where('id', '!=', $page->id)->first();
 
         $content = '<table id="table"><tbody><tr><td>test</td></tr></tbody></table>';
         $secondPage->html = $content;
@@ -66,9 +74,26 @@ class PageContentTest extends TestCase
         $page->html = "{{@{$secondPage->id}#table}}";
         $page->save();
 
-        $this->asEditor();
-        $pageResp = $this->get($page->getUrl());
-        $pageResp->assertSee($content);
+        $pageResp = $this->asEditor()->get($page->getUrl());
+        $pageResp->assertSee($content, false);
+    }
+
+    public function test_page_includes_do_not_break_code()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        /** @var Page $secondPage */
+        $secondPage = Page::query()->where('id', '!=', $page->id)->first();
+
+        $content = '<pre id="bkmrk-code"><code>var cat = null;</code></pre>';
+        $secondPage->html = $content;
+        $secondPage->save();
+
+        $page->html = "{{@{$secondPage->id}#bkmrk-code}}";
+        $page->save();
+
+        $pageResp = $this->asEditor()->get($page->getUrl());
+        $pageResp->assertSee($content, false);
     }
 
     public function test_page_includes_rendered_on_book_export()
@@ -93,14 +118,14 @@ class PageContentTest extends TestCase
     public function test_page_content_scripts_removed_by_default()
     {
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
         $script = 'abc123<script>console.log("hello-test")</script>abc123';
         $page->html = "escape {$script}";
         $page->save();
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertStatus(200);
-        $pageView->assertDontSee($script);
+        $pageView->assertDontSee($script, false);
         $pageView->assertSee('abc123abc123');
     }
 
@@ -116,7 +141,7 @@ class PageContentTest extends TestCase
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -127,21 +152,32 @@ class PageContentTest extends TestCase
             $pageView->assertElementNotContains('.page-content', '<script>');
             $pageView->assertElementNotContains('.page-content', '</script>');
         }
-
     }
 
-    public function test_iframe_js_and_base64_urls_are_removed()
+    public function test_js_and_base64_src_urls_are_removed()
     {
         $checks = [
             '<iframe src="javascript:alert(document.cookie)"></iframe>',
+            '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
+            '<iframe src="JavAScRipT:alert(document.cookie)"></iframe>',
             '<iframe SRC=" javascript: alert(document.cookie)"></iframe>',
             '<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<iframe src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
             '<iframe src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
-            '<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>'
+            '<img src="javascript:alert(document.cookie)"/>',
+            '<img src="JavAScRipT:alert(document.cookie)"/>',
+            '<img src="JavAScRipT:alert(document.cookie)"/>',
+            '<img SRC=" javascript: alert(document.cookie)"/>',
+            '<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<img src="DaTa:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<img src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg=="/>',
+            '<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+            '<iframe SRCdoc="<script>window.alert(document.cookie)</script>"></iframe>',
+            '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -150,24 +186,26 @@ class PageContentTest extends TestCase
             $pageView = $this->get($page->getUrl());
             $pageView->assertStatus(200);
             $pageView->assertElementNotContains('.page-content', '<iframe>');
+            $pageView->assertElementNotContains('.page-content', '<img');
             $pageView->assertElementNotContains('.page-content', '</iframe>');
             $pageView->assertElementNotContains('.page-content', 'src=');
             $pageView->assertElementNotContains('.page-content', 'javascript:');
             $pageView->assertElementNotContains('.page-content', 'data:');
             $pageView->assertElementNotContains('.page-content', 'base64');
         }
-
     }
 
     public function test_javascript_uri_links_are_removed()
     {
         $checks = [
             '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
-            '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
+            '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>',
+            '<a id="xss" href="JaVaScRiPt: alert(document.cookie)>Click me</a>',
+            '<a id="xss" href=" JaVaScRiPt: alert(document.cookie)>Click me</a>',
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -175,20 +213,23 @@ class PageContentTest extends TestCase
 
             $pageView = $this->get($page->getUrl());
             $pageView->assertStatus(200);
-            $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+            $pageView->assertElementNotContains('.page-content', '<a id="xss"');
             $pageView->assertElementNotContains('.page-content', 'href=javascript:');
         }
     }
+
     public function test_form_actions_with_javascript_are_removed()
     {
         $checks = [
             '<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
+            '<form ><button id="xss" formaction="JaVaScRiPt:alert(document.domain)">Click me</button></form>',
             '<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
-            '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
+            '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>',
+            '<form id="xss" action="JaVaScRiPt:alert(document.domain)"><input type=submit value=Submit></form>',
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -203,15 +244,17 @@ class PageContentTest extends TestCase
             $pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
         }
     }
-    
+
     public function test_metadata_redirects_are_removed()
     {
         $checks = [
             '<meta http-equiv="refresh" content="0; url=//external_url">',
+            '<meta http-equiv="refresh" ConTeNt="0; url=//external_url">',
+            '<meta http-equiv="refresh" content="0; UrL=//external_url">',
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -225,33 +268,36 @@ class PageContentTest extends TestCase
             $pageView->assertElementNotContains('.page-content', 'external_url');
         }
     }
+
     public function test_page_inline_on_attributes_removed_by_default()
     {
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
         $script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
         $page->html = "escape {$script}";
         $page->save();
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertStatus(200);
-        $pageView->assertDontSee($script);
-        $pageView->assertSee('<p>Hello</p>');
+        $pageView->assertDontSee($script, false);
+        $pageView->assertSee('<p>Hello</p>', false);
     }
 
     public function test_more_complex_inline_on_attributes_escaping_scenarios()
     {
         $checks = [
             '<p onclick="console.log(\'test\')">Hello</p>',
+            '<p OnCliCk="console.log(\'test\')">Hello</p>',
             '<div>Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p>',
             '<div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div>',
             '<div><div><div><div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div></div></div></div>',
             '<div onclick="console.log(\'test\')">Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p><div></div>',
             '<a a="<img src=1 onerror=\'alert(1)\'> ',
+            '\<a onclick="alert(document.cookie)"\>xss link\</a\>',
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -261,13 +307,12 @@ class PageContentTest extends TestCase
             $pageView->assertStatus(200);
             $pageView->assertElementNotContains('.page-content', 'onclick');
         }
-
     }
 
     public function test_page_content_scripts_show_when_configured()
     {
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
         config()->push('app.allow_content_scripts', 'true');
 
         $script = 'abc123<script>console.log("hello-test")</script>abc123';
@@ -275,14 +320,36 @@ class PageContentTest extends TestCase
         $page->save();
 
         $pageView = $this->get($page->getUrl());
-        $pageView->assertSee($script);
+        $pageView->assertSee($script, false);
         $pageView->assertDontSee('abc123abc123');
     }
 
+    public function test_svg_xlink_hrefs_are_removed()
+    {
+        $checks = [
+            '<svg id="test" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100"><a xlink:href="javascript:alert(document.domain)"><rect x="0" y="0" width="100" height="100" /></a></svg>',
+            '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64 ,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjAiIGN4PSIwIiBjeT0iMCIgc3R5bGU9ImZpbGw6ICNGMDAiPgo8c2V0IGF0dHJpYnV0ZU5hbWU9ImZpbGwiIGF0dHJpYnV0ZVR5cGU9IkNTUyIgb25iZWdpbj0nYWxlcnQoZG9jdW1lbnQuZG9tYWluKScKb25lbmQ9J2FsZXJ0KCJvbmVuZCIpJyB0bz0iIzAwRiIgYmVnaW49IjBzIiBkdXI9Ijk5OXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/></svg>',
+        ];
+
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', 'alert');
+            $pageView->assertElementNotContains('.page-content', 'xlink:href');
+            $pageView->assertElementNotContains('.page-content', 'application/xml');
+        }
+    }
+
     public function test_page_inline_on_attributes_show_if_configured()
     {
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
         config()->push('app.allow_content_scripts', 'true');
 
         $script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
@@ -290,21 +357,21 @@ class PageContentTest extends TestCase
         $page->save();
 
         $pageView = $this->get($page->getUrl());
-        $pageView->assertSee($script);
-        $pageView->assertDontSee('<p>Hello</p>');
+        $pageView->assertSee($script, false);
+        $pageView->assertDontSee('<p>Hello</p>', false);
     }
 
     public function test_duplicate_ids_does_not_break_page_render()
     {
         $this->asEditor();
-        $pageA = Page::first();
+        $pageA = Page::query()->first();
         $pageB = Page::query()->where('id', '!=', $pageA->id)->first();
 
         $content = '<ul id="bkmrk-xxx-%28"></ul> <ul id="bkmrk-xxx-%28"></ul>';
         $pageA->html = $content;
         $pageA->save();
 
-        $pageB->html = '<ul id="bkmrk-xxx-%28"></ul> <p>{{@'. $pageA->id .'#test}}</p>';
+        $pageB->html = '<ul id="bkmrk-xxx-%28"></ul> <p>{{@' . $pageA->id . '#test}}</p>';
         $pageB->save();
 
         $pageView = $this->get($pageB->getUrl());
@@ -314,33 +381,33 @@ class PageContentTest extends TestCase
     public function test_duplicate_ids_fixed_on_page_save()
     {
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         $content = '<ul id="bkmrk-test"><li>test a</li><li><ul id="bkmrk-test"><li>test b</li></ul></li></ul>';
         $pageSave = $this->put($page->getUrl(), [
-            'name' => $page->name,
-            'html' => $content,
-            'summary' => ''
+            'name'    => $page->name,
+            'html'    => $content,
+            'summary' => '',
         ]);
         $pageSave->assertRedirect();
 
-        $updatedPage = Page::where('id', '=', $page->id)->first();
-        $this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
+        $updatedPage = Page::query()->where('id', '=', $page->id)->first();
+        $this->assertEquals(substr_count($updatedPage->html, 'bkmrk-test"'), 1);
     }
 
     public function test_anchors_referencing_non_bkmrk_ids_rewritten_after_save()
     {
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         $content = '<h1 id="non-standard-id">test</h1><p><a href="#non-standard-id">link</a></p>';
         $this->put($page->getUrl(), [
-            'name' => $page->name,
-            'html' => $content,
-            'summary' => ''
+            'name'    => $page->name,
+            'html'    => $content,
+            'summary' => '',
         ]);
 
-        $updatedPage = Page::where('id', '=', $page->id)->first();
+        $updatedPage = Page::query()->where('id', '=', $page->id)->first();
         $this->assertStringContainsString('id="bkmrk-test"', $updatedPage->html);
         $this->assertStringContainsString('href="#bkmrk-test"', $updatedPage->html);
     }
@@ -354,21 +421,21 @@ class PageContentTest extends TestCase
         $this->assertCount(3, $navMap);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h1',
-            'link' => '#testa',
-            'text' => 'Hello',
-            'level' => 1,
+            'link'     => '#testa',
+            'text'     => 'Hello',
+            'level'    => 1,
         ], $navMap[0]);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h2',
-            'link' => '#testb',
-            'text' => 'There',
-            'level' => 2,
+            'link'     => '#testb',
+            'text'     => 'There',
+            'level'    => 2,
         ], $navMap[1]);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h3',
-            'link' => '#testc',
-            'text' => 'Donkey',
-            'level' => 3,
+            'link'     => '#testc',
+            'text'     => 'Donkey',
+            'level'    => 3,
         ], $navMap[2]);
     }
 
@@ -381,8 +448,8 @@ class PageContentTest extends TestCase
         $this->assertCount(1, $navMap);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h1',
-            'link' => '#testa',
-            'text' => 'Hello'
+            'link'     => '#testa',
+            'text'     => 'Hello',
         ], $navMap[0]);
     }
 
@@ -395,15 +462,15 @@ class PageContentTest extends TestCase
         $this->assertCount(3, $navMap);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h4',
-            'level' => 1,
+            'level'    => 1,
         ], $navMap[0]);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h5',
-            'level' => 2,
+            'level'    => 2,
         ], $navMap[1]);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h6',
-            'level' => 3,
+            'level'    => 3,
         ], $navMap[2]);
     }
 
@@ -432,7 +499,7 @@ class PageContentTest extends TestCase
 | Paragraph   | Text        |';
         $this->put($page->getUrl(), [
             'name' => $page->name,  'markdown' => $content,
-            'html' => '', 'summary' => ''
+            'html' => '', 'summary' => '',
         ]);
 
         $page->refresh();
@@ -451,7 +518,7 @@ class PageContentTest extends TestCase
 - [x] Item b';
         $this->put($page->getUrl(), [
             'name' => $page->name,  'markdown' => $content,
-            'html' => '', 'summary' => ''
+            'html' => '', 'summary' => '',
         ]);
 
         $page->refresh();
@@ -459,7 +526,8 @@ class PageContentTest extends TestCase
         $this->assertStringContainsString('type="checkbox"', $page->html);
 
         $pageView = $this->get($page->getUrl());
-        $pageView->assertElementExists('.page-content input[type=checkbox]');
+        $pageView->assertElementExists('.page-content li.task-list-item input[type=checkbox]');
+        $pageView->assertElementExists('.page-content li.task-list-item input[type=checkbox][checked=checked]');
     }
 
     public function test_page_markdown_strikethrough_rendering()
@@ -470,7 +538,7 @@ class PageContentTest extends TestCase
         $content = '~~some crossed out text~~';
         $this->put($page->getUrl(), [
             'name' => $page->name,  'markdown' => $content,
-            'html' => '', 'summary' => ''
+            'html' => '', 'summary' => '',
         ]);
 
         $page->refresh();
@@ -479,4 +547,147 @@ class PageContentTest extends TestCase
         $pageView = $this->get($page->getUrl());
         $pageView->assertElementExists('.page-content p > s');
     }
+
+    public function test_page_markdown_single_html_comment_saving()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $content = '<!-- Test Comment -->';
+        $this->put($page->getUrl(), [
+            'name' => $page->name,  'markdown' => $content,
+            'html' => '', 'summary' => '',
+        ]);
+
+        $page->refresh();
+        $this->assertStringMatchesFormat($content, $page->html);
+
+        $pageView = $this->get($page->getUrl());
+        $pageView->assertStatus(200);
+        $pageView->assertSee($content, false);
+    }
+
+    public function test_base64_images_get_extracted_from_page_content()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $this->put($page->getUrl(), [
+            'name' => $page->name, 'summary' => '',
+            'html' => '<p>test<img src="data:image/jpeg;base64,' . $this->base64Jpeg . '"/></p>',
+        ]);
+
+        $page->refresh();
+        $this->assertStringMatchesFormat('%A<p%A>test<img src="http://localhost/uploads/images/gallery/%A.jpeg">%A</p>%A', $page->html);
+
+        $matches = [];
+        preg_match('/src="https:\/\/p.rizon.top:443\/http\/localhost(.*?)"/', $page->html, $matches);
+        $imagePath = $matches[1];
+        $imageFile = public_path($imagePath);
+        $this->assertEquals(base64_decode($this->base64Jpeg), file_get_contents($imageFile));
+
+        $this->deleteImage($imagePath);
+    }
+
+    public function test_base64_images_get_extracted_when_containing_whitespace()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $base64PngWithWhitespace = "iVBORw0KGg\noAAAANSUhE\tUgAAAAEAAAA BCA   YAAAAfFcSJAAA\n\t ACklEQVR4nGMAAQAABQAB";
+        $base64PngWithoutWhitespace = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQAB';
+        $this->put($page->getUrl(), [
+            'name' => $page->name, 'summary' => '',
+            'html' => '<p>test<img src="data:image/png;base64,' . $base64PngWithWhitespace . '"/></p>',
+        ]);
+
+        $page->refresh();
+        $this->assertStringMatchesFormat('%A<p%A>test<img src="http://localhost/uploads/images/gallery/%A.png">%A</p>%A', $page->html);
+
+        $matches = [];
+        preg_match('/src="https:\/\/p.rizon.top:443\/http\/localhost(.*?)"/', $page->html, $matches);
+        $imagePath = $matches[1];
+        $imageFile = public_path($imagePath);
+        $this->assertEquals(base64_decode($base64PngWithoutWhitespace), file_get_contents($imageFile));
+
+        $this->deleteImage($imagePath);
+    }
+
+    public function test_base64_images_within_html_blanked_if_not_supported_extension_for_extract()
+    {
+        // Relevant to https://github.com/BookStackApp/BookStack/issues/3010 and other cases
+        $extensions = [
+            'jiff', 'pngr', 'png ', ' png', '.png', 'png.', 'p.ng', ',png',
+            'data:image/png', ',data:image/png',
+        ];
+
+        foreach ($extensions as $extension) {
+            $this->asEditor();
+            $page = Page::query()->first();
+
+            $this->put($page->getUrl(), [
+                'name' => $page->name, 'summary' => '',
+                'html' => '<p>test<img src="data:image/' . $extension . ';base64,' . $this->base64Jpeg . '"/></p>',
+            ]);
+
+            $page->refresh();
+            $this->assertStringContainsString('<img src=""', $page->html);
+        }
+    }
+
+    public function test_base64_images_get_extracted_from_markdown_page_content()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $this->put($page->getUrl(), [
+            'name'     => $page->name, 'summary' => '',
+            'markdown' => 'test ![test](data:image/jpeg;base64,' . $this->base64Jpeg . ')',
+        ]);
+
+        $page->refresh();
+        $this->assertStringMatchesFormat('%A<p%A>test <img src="http://localhost/uploads/images/gallery/%A.jpeg" alt="test">%A</p>%A', $page->html);
+
+        $matches = [];
+        preg_match('/src="https:\/\/p.rizon.top:443\/http\/localhost(.*?)"/', $page->html, $matches);
+        $imagePath = $matches[1];
+        $imageFile = public_path($imagePath);
+        $this->assertEquals(base64_decode($this->base64Jpeg), file_get_contents($imageFile));
+
+        $this->deleteImage($imagePath);
+    }
+
+    public function test_base64_images_within_markdown_blanked_if_not_supported_extension_for_extract()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $this->put($page->getUrl(), [
+            'name'     => $page->name, 'summary' => '',
+            'markdown' => 'test ![test](data:image/jiff;base64,' . $this->base64Jpeg . ')',
+        ]);
+
+        $page->refresh();
+        $this->assertStringContainsString('<img src=""', $page->html);
+    }
+
+    public function test_nested_headers_gets_assigned_an_id()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $content = '<table><tbody><tr><td><h5>Simple Test</h5></td></tr></tbody></table>';
+        $this->put($page->getUrl(), [
+            'name'    => $page->name,
+            'html'    => $content,
+            'summary' => '',
+        ]);
+
+        $updatedPage = Page::query()->where('id', '=', $page->id)->first();
+
+        // The top level <table> node will get assign the bkmrk-simple-test id because the system will
+        // take the node value of h5
+        // So the h5 should get the bkmrk-simple-test-1 id
+        $this->assertStringContainsString('<h5 id="bkmrk-simple-test-1">Simple Test</h5>', $updatedPage->html);
+    }
 }
index 0e3980c6702217cd10043748a9d8ad89dbb18bcc..cac1babea708f3340ac69e41f17f8cf5e582a88b 100644 (file)
@@ -1,11 +1,18 @@
-<?php namespace Tests\Entity;
+<?php
 
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use BookStack\Entities\Repos\PageRepo;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class PageDraftTest extends BrowserKitTest
+class PageDraftTest extends TestCase
 {
+    /**
+     * @var Page
+     */
     protected $page;
 
     /**
@@ -13,104 +20,188 @@ class PageDraftTest extends BrowserKitTest
      */
     protected $pageRepo;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
-        $this->page = \BookStack\Entities\Models\Page::first();
-        $this->pageRepo = app(PageRepo::class);
+        $this->page = Page::query()->first();
+        $this->pageRepo = app()->make(PageRepo::class);
     }
 
     public function test_draft_content_shows_if_available()
     {
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $addedContent);
+
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->seeInField('html', $newContent);
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementContains('[name="html"]', $newContent);
     }
 
     public function test_draft_not_visible_by_others()
     {
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $addedContent);
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $newUser = $this->getEditor();
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
 
-        $this->actingAs($newUser)->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $newContent);
+        $this->actingAs($newUser)->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $newContent);
     }
 
     public function test_alert_message_shows_if_editing_draft()
     {
         $this->asAdmin();
         $this->pageRepo->updatePageDraft($this->page, ['html' => 'test content']);
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->see('You are currently editing a draft');
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertSee('You are currently editing a draft');
     }
 
     public function test_alert_message_shows_if_someone_else_editing()
     {
-        $nonEditedPage = \BookStack\Entities\Models\Page::take(10)->get()->last();
+        $nonEditedPage = Page::query()->take(10)->get()->last();
         $addedContent = '<p>test message content</p>';
-        $this->asAdmin()->visit($this->page->getUrl('/edit'))
-            ->dontSeeInField('html', $addedContent);
+        $this->asAdmin()->get($this->page->getUrl('/edit'))
+            ->assertElementNotContains('[name="html"]', $addedContent);
 
         $newContent = $this->page->html . $addedContent;
         $newUser = $this->getEditor();
         $this->pageRepo->updatePageDraft($this->page, ['html' => $newContent]);
 
         $this->actingAs($newUser)
-            ->visit($this->page->getUrl('/edit'))
-            ->see('Admin has started editing this page');
-            $this->flushSession();
-        $this->visit($nonEditedPage->getUrl() . '/edit')
-            ->dontSeeInElement('.notification', 'Admin has started editing this page');
+            ->get($this->page->getUrl('/edit'))
+            ->assertSee('Admin has started editing this page');
+        $this->flushSession();
+        $this->get($nonEditedPage->getUrl() . '/edit')
+            ->assertElementNotContains('.notification', 'Admin has started editing this page');
+    }
+
+    public function test_draft_save_shows_alert_if_draft_older_than_last_page_update()
+    {
+        $admin = $this->getAdmin();
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft</p>',
+        ]);
+
+        /** @var PageRevision $draft */
+        $draft = $page->allRevisions()
+            ->where('type', '=', 'update_draft')
+            ->where('created_by', '=', $editor->id)
+            ->first();
+        $draft->created_at = now()->subMinute(1);
+        $draft->save();
+
+        $this->actingAs($admin)->put($page->refresh()->getUrl(), [
+            'name' => $page->name,
+            'html' => '<p>admin update</p>',
+        ]);
+
+        $resp = $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft again</p>',
+        ]);
+
+        $resp->assertJson([
+            'warning' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
+        ]);
+    }
+
+    public function test_draft_save_shows_alert_if_draft_edit_started_by_someone_else()
+    {
+        $admin = $this->getAdmin();
+        $editor = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($admin)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft</p>',
+        ]);
+
+        $resp = $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+            'name' => $page->name,
+            'html' => '<p>updated draft again</p>',
+        ]);
+
+        $resp->assertJson([
+            'warning' => 'Admin has started editing this page in the last 60 minutes. Take care not to overwrite each other\'s updates!',
+        ]);
     }
 
     public function test_draft_pages_show_on_homepage()
     {
-        $book = \BookStack\Entities\Models\Book::first();
-        $this->asAdmin()->visit('/')
-            ->dontSeeInElement('#recent-drafts', 'New Page')
-            ->visit($book->getUrl() . '/create-page')
-            ->visit('/')
-            ->seeInElement('#recent-drafts', 'New Page');
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $this->asAdmin()->get('/')
+            ->assertElementNotContains('#recent-drafts', 'New Page');
+
+        $this->get($book->getUrl() . '/create-page');
+
+        $this->get('/')->assertElementContains('#recent-drafts', 'New Page');
     }
 
     public function test_draft_pages_not_visible_by_others()
     {
-        $book = \BookStack\Entities\Models\Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $chapter = $book->chapters->first();
         $newUser = $this->getEditor();
 
-        $this->actingAs($newUser)->visit('/')
-            ->visit($book->getUrl('/create-page'))
-            ->visit($chapter->getUrl('/create-page'))
-            ->visit($book->getUrl())
-            ->seeInElement('.book-contents', 'New Page');
-
-        $this->asAdmin()
-            ->visit($book->getUrl())
-            ->dontSeeInElement('.book-contents', 'New Page')
-            ->visit($chapter->getUrl())
-            ->dontSeeInElement('.book-contents', 'New Page');
+        $this->actingAs($newUser)->get($book->getUrl('/create-page'));
+        $this->get($chapter->getUrl('/create-page'));
+        $this->get($book->getUrl())
+            ->assertElementContains('.book-contents', 'New Page');
+
+        $this->asAdmin()->get($book->getUrl())
+            ->assertElementNotContains('.book-contents', 'New Page');
+        $this->get($chapter->getUrl())
+            ->assertElementNotContains('.book-contents', 'New Page');
     }
 
     public function test_page_html_in_ajax_fetch_response()
     {
         $this->asAdmin();
+        /** @var Page $page */
         $page = Page::query()->first();
 
-        $this->getJson('/ajax/page/' . $page->id);
-        $this->seeJson([
+        $this->getJson('/ajax/page/' . $page->id)->assertJson([
             'html' => $page->html,
         ]);
     }
 
+    public function test_updating_page_draft_with_markdown_retains_markdown_content()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->where('book_id', '=', $book->id)->firstOrFail();
+
+        $resp = $this->put('/ajax/page/' . $draft->id . '/save-draft', [
+            'name'     => 'My updated draft',
+            'markdown' => "# My markdown page\n\n[A link](https://example.com)",
+            'html'     => '<p>checking markdown takes priority over this</p>',
+        ]);
+        $resp->assertOk();
+
+        $this->assertDatabaseHas('pages', [
+            'id'       => $draft->id,
+            'draft'    => true,
+            'name'     => 'My updated draft',
+            'markdown' => "# My markdown page\n\n[A link](https://example.com)",
+        ]);
+
+        $draft->refresh();
+        $this->assertStringContainsString('href="https://example.com"', $draft->html);
+    }
 }
diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php
new file mode 100644 (file)
index 0000000..c06aa5b
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PageEditorTest extends TestCase
+{
+    /** @var Page */
+    protected $page;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        $this->page = Page::query()->first();
+    }
+
+    public function test_default_editor_is_wysiwyg()
+    {
+        $this->assertEquals('wysiwyg', setting('app-editor'));
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementExists('#html-editor');
+    }
+
+    public function test_markdown_setting_shows_markdown_editor()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementNotExists('#html-editor')
+            ->assertElementExists('#markdown-editor');
+    }
+
+    public function test_markdown_content_given_to_editor()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+
+        $mdContent = '# hello. This is a test';
+        $this->page->markdown = $mdContent;
+        $this->page->save();
+
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementContains('[name="markdown"]', $mdContent);
+    }
+
+    public function test_html_content_given_to_editor_if_no_markdown()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        $this->asAdmin()->get($this->page->getUrl() . '/edit')
+            ->assertElementContains('[name="markdown"]', $this->page->html);
+    }
+
+    public function test_empty_markdown_still_saves_without_error()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        $draft = Page::query()->where('book_id', '=', $book->id)
+            ->where('draft', '=', true)->first();
+
+        $details = [
+            'name'     => 'my page',
+            'markdown' => '',
+        ];
+        $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
+        $resp->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'markdown' => $details['markdown'],
+            'id'       => $draft->id,
+            'draft'    => false,
+        ]);
+    }
+
+    public function test_back_link_in_editor_has_correct_url()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->firstOrFail();
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        /** @var Chapter $chapter */
+        $chapter = $book->chapters()->firstOrFail();
+        /** @var Page $draft */
+        $draft = $book->pages()->where('draft', '=', true)->firstOrFail();
+
+        // Book draft goes back to book
+        $resp = $this->get($book->getUrl("/draft/{$draft->id}"));
+        $resp->assertElementContains('a[href="' . $book->getUrl() . '"]', 'Back');
+
+        // Chapter draft goes back to chapter
+        $draft->chapter_id = $chapter->id;
+        $draft->save();
+        $resp = $this->get($book->getUrl("/draft/{$draft->id}"));
+        $resp->assertElementContains('a[href="' . $chapter->getUrl() . '"]', 'Back');
+
+        // Saved page goes back to page
+        $this->post($book->getUrl("/draft/{$draft->id}"), ['name' => 'Updated', 'html' => 'Updated']);
+        $draft->refresh();
+        $resp = $this->get($draft->getUrl('/edit'));
+        $resp->assertElementContains('a[href="' . $draft->getUrl() . '"]', 'Back');
+    }
+}
index 62fbfbf3140d46b1544b9af26fdb60e38a4c8f9b..2ed7d3b411e86b8990cc0b000740ec2a79a26bbd 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
@@ -47,8 +49,7 @@ class PageRevisionTest extends TestCase
         $page = Page::first();
         $pageRepo->update($page, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
         $pageRepo->update($page, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
-        $page =  Page::find($page->id);
-
+        $page = Page::find($page->id);
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee('abc123');
@@ -56,7 +57,7 @@ class PageRevisionTest extends TestCase
 
         $revToRestore = $page->revisions()->where('name', 'like', '%abc123')->first();
         $restoreReq = $this->put($page->getUrl() . '/revisions/' . $revToRestore->id . '/restore');
-        $page =  Page::find($page->id);
+        $page = Page::find($page->id);
 
         $restoreReq->assertStatus(302);
         $restoreReq->assertRedirect($page->getUrl());
@@ -89,7 +90,7 @@ class PageRevisionTest extends TestCase
 
         $pageView = $this->get($page->getUrl());
         $this->assertDatabaseHas('pages', [
-            'id' => $page->id,
+            'id'       => $page->id,
             'markdown' => '## New Content def456',
         ]);
         $pageView->assertSee('abc123');
@@ -112,8 +113,8 @@ class PageRevisionTest extends TestCase
 
         $this->assertDatabaseHas('page_revisions', [
             'page_id' => $page->id,
-            'text' => 'new contente def456',
-            'type' => 'version',
+            'text'    => 'new contente def456',
+            'type'    => 'version',
             'summary' => "Restored from #{$revToRestore->id}; My first update",
         ]);
     }
@@ -125,7 +126,7 @@ class PageRevisionTest extends TestCase
         $resp = $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
         $resp->assertStatus(302);
 
-        $this->assertTrue(Page::find($page->id)->revision_count === $startCount+1);
+        $this->assertTrue(Page::find($page->id)->revision_count === $startCount + 1);
     }
 
     public function test_revision_count_shown_in_page_meta()
@@ -141,7 +142,8 @@ class PageRevisionTest extends TestCase
         $pageView->assertSee('Revision #' . $page->revision_count);
     }
 
-    public function test_revision_deletion() {
+    public function test_revision_deletion()
+    {
         $page = Page::first();
         $this->asEditor()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html', 'summary' => 'Update a']);
 
@@ -201,4 +203,4 @@ class PageRevisionTest extends TestCase
         $revisionCount = $page->revisions()->count();
         $this->assertEquals(12, $revisionCount);
     }
-}
\ No newline at end of file
+}
index a5594e8b8df06ececf95ae1df6d8256590738e91..3d16895102f5a5f6af570119f2e5234a365e7aaf 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Models\Page;
 use Tests\TestCase;
@@ -27,14 +29,14 @@ class PageTemplateTest extends TestCase
         $this->actingAs($editor);
 
         $pageUpdateData = [
-            'name' => $page->name,
-            'html' => $page->html,
+            'name'     => $page->name,
+            'html'     => $page->html,
             'template' => 'true',
         ];
 
         $this->put($page->getUrl(), $pageUpdateData);
         $this->assertDatabaseHas('pages', [
-            'id' => $page->id,
+            'id'       => $page->id,
             'template' => false,
         ]);
 
@@ -42,7 +44,7 @@ class PageTemplateTest extends TestCase
 
         $this->put($page->getUrl(), $pageUpdateData);
         $this->assertDatabaseHas('pages', [
-            'id' => $page->id,
+            'id'       => $page->id,
             'template' => true,
         ]);
     }
@@ -64,7 +66,7 @@ class PageTemplateTest extends TestCase
         $templateFetch = $this->get('/templates/' . $page->id);
         $templateFetch->assertStatus(200);
         $templateFetch->assertJson([
-            'html' => $content,
+            'html'     => $content,
             'markdown' => '',
         ]);
     }
@@ -86,5 +88,4 @@ class PageTemplateTest extends TestCase
         $templatesFetch->assertSee($page->name);
         $templatesFetch->assertSee('pagination');
     }
-
-}
\ No newline at end of file
+}
index 615bae21eb6f6940c6d0cc54adab25369c55ed61..efe5400a0f6f8b2701a17d670927a5b116fa3a18 100644 (file)
@@ -1,11 +1,41 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use Carbon\Carbon;
 use Tests\TestCase;
 
 class PageTest extends TestCase
 {
+    public function test_create()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = Page::factory()->make([
+            'name' => 'My First Page',
+        ]);
+
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $resp->assertElementContains('a[href="' . $chapter->getUrl('/create-page') . '"]', 'New Page');
+
+        $resp = $this->get($chapter->getUrl('/create-page'));
+        /** @var Page $draftPage */
+        $draftPage = Page::query()
+            ->where('draft', '=', true)
+            ->orderBy('created_at', 'desc')
+            ->first();
+        $resp->assertRedirect($draftPage->getUrl());
+
+        $resp = $this->get($draftPage->getUrl());
+        $resp->assertElementContains('form[action="' . $draftPage->getUrl() . '"][method="POST"]', 'Save Page');
+
+        $resp = $this->post($draftPage->getUrl(), $draftPage->only('name', 'html'));
+        $draftPage->refresh();
+        $resp->assertRedirect($draftPage->getUrl());
+    }
 
     public function test_page_view_when_creator_is_deleted_but_owner_exists()
     {
@@ -33,22 +63,22 @@ class PageTest extends TestCase
 
         $details = [
             'markdown' => '# a title',
-            'html' => '<h1>a title</h1>',
-            'name' => 'my page',
+            'html'     => '<h1>a title</h1>',
+            'name'     => 'my page',
         ];
         $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details);
         $resp->assertRedirect();
 
         $this->assertDatabaseHas('pages', [
             'markdown' => $details['markdown'],
-            'name' => $details['name'],
-            'id' => $draft->id,
-            'draft' => false
+            'name'     => $details['name'],
+            'id'       => $draft->id,
+            'draft'    => false,
         ]);
 
         $draft->refresh();
-        $resp = $this->get($draft->getUrl("/edit"));
-        $resp->assertSee("# a title");
+        $resp = $this->get($draft->getUrl('/edit'));
+        $resp->assertSee('# a title');
     }
 
     public function test_page_delete()
@@ -71,6 +101,33 @@ class PageTest extends TestCase
         $redirectReq->assertNotificationContains('Page Successfully Deleted');
     }
 
+    public function test_page_full_delete_removes_all_revisions()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->revisions()->create([
+            'html' => '<p>ducks</p>',
+            'name' => 'my page revision',
+            'type' => 'draft',
+        ]);
+        $page->revisions()->create([
+            'html' => '<p>ducks</p>',
+            'name' => 'my page revision',
+            'type' => 'revision',
+        ]);
+
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+        ]);
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->asAdmin()->post('/settings/recycle-bin/empty');
+
+        $this->assertDatabaseMissing('page_revisions', [
+            'page_id' => $page->id,
+        ]);
+    }
+
     public function test_page_copy()
     {
         $page = Page::first();
@@ -85,7 +142,7 @@ class PageTest extends TestCase
 
         $movePageResp = $this->post($page->getUrl('/copy'), [
             'entity_selection' => 'book:' . $newBook->id,
-            'name' => 'My copied test page'
+            'name'             => 'My copied test page',
         ]);
         $pageCopy = Page::where('name', '=', 'My copied test page')->first();
 
@@ -104,7 +161,7 @@ class PageTest extends TestCase
 
         $this->asEditor()->post($page->getUrl('/copy'), [
             'entity_selection' => 'book:' . $newBook->id,
-            'name' => 'My copied test page'
+            'name'             => 'My copied test page',
         ]);
         $pageCopy = Page::where('name', '=', 'My copied test page')->first();
 
@@ -121,7 +178,7 @@ class PageTest extends TestCase
         $resp->assertSee('Copy Page');
 
         $movePageResp = $this->post($page->getUrl('/copy'), [
-            'name' => 'My copied test page'
+            'name' => 'My copied test page',
         ]);
 
         $pageCopy = Page::where('name', '=', 'My copied test page')->first();
@@ -151,14 +208,127 @@ class PageTest extends TestCase
 
         $movePageResp = $this->post($page->getUrl('/copy'), [
             'entity_selection' => 'book:' . $newBook->id,
-            'name' => 'My copied test page'
+            'name'             => 'My copied test page',
         ]);
         $movePageResp->assertRedirect();
 
         $this->assertDatabaseHas('pages', [
-            'name' => 'My copied test page',
+            'name'       => 'My copied test page',
             'created_by' => $viewer->id,
-            'book_id' => $newBook->id,
+            'book_id'    => $newBook->id,
+        ]);
+    }
+
+    public function test_old_page_slugs_redirect_to_new_pages()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        // Need to save twice since revisions are not generated in seeder.
+        $this->asAdmin()->put($page->getUrl(), [
+            'name' => 'super test',
+            'html' => '<p></p>',
+        ]);
+
+        $page->refresh();
+        $pageUrl = $page->getUrl();
+
+        $this->put($pageUrl, [
+            'name' => 'super test page',
+            'html' => '<p></p>',
+        ]);
+
+        $this->get($pageUrl)
+            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
+    }
+
+    public function test_page_within_chapter_deletion_returns_to_chapter()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages()->first();
+
+        $this->asEditor()->delete($page->getUrl())
+            ->assertRedirect($chapter->getUrl());
+    }
+
+    public function test_recently_updated_pages_view()
+    {
+        $user = $this->getEditor();
+        $content = $this->createEntityChainBelongingToUser($user);
+
+        $this->asAdmin()->get('/pages/recently-updated')
+            ->assertElementContains('.entity-list .page:nth-child(1)', $content['page']->name);
+    }
+
+    public function test_recently_updated_pages_view_shows_updated_by_details()
+    {
+        $user = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->actingAs($user)->put($page->getUrl(), [
+            'name' => 'Updated title',
+            'html' => '<p>Updated content</p>',
+        ]);
+
+        $resp = $this->asAdmin()->get('/pages/recently-updated');
+        $resp->assertElementContains('.entity-list .page:nth-child(1)', 'Updated 1 second ago by ' . $user->name);
+    }
+
+    public function test_recently_updated_pages_view_shows_parent_chain()
+    {
+        $user = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->whereNotNull('chapter_id')->first();
+
+        $this->actingAs($user)->put($page->getUrl(), [
+            'name' => 'Updated title',
+            'html' => '<p>Updated content</p>',
         ]);
+
+        $resp = $this->asAdmin()->get('/pages/recently-updated');
+        $resp->assertElementContains('.entity-list .page:nth-child(1)', $page->chapter->getShortName(42));
+        $resp->assertElementContains('.entity-list .page:nth-child(1)', $page->book->getShortName(42));
+    }
+
+    public function test_recently_updated_pages_view_does_not_show_parent_if_not_visible()
+    {
+        $user = $this->getEditor();
+        /** @var Page $page */
+        $page = Page::query()->whereNotNull('chapter_id')->first();
+
+        $this->actingAs($user)->put($page->getUrl(), [
+            'name' => 'Updated title',
+            'html' => '<p>Updated content</p>',
+        ]);
+
+        $this->setEntityRestrictions($page->book);
+        $this->setEntityRestrictions($page, ['view'], [$user->roles->first()]);
+
+        $resp = $this->get('/pages/recently-updated');
+        $resp->assertDontSee($page->book->getShortName(42));
+        $resp->assertDontSee($page->chapter->getShortName(42));
+        $resp->assertElementContains('.entity-list .page:nth-child(1)', 'Updated title');
+    }
+
+    public function test_recently_updated_pages_on_home()
+    {
+        /** @var Page $page */
+        $page = Page::query()->orderBy('updated_at', 'asc')->first();
+        Page::query()->where('id', '!=', $page->id)->update([
+            'updated_at' => Carbon::now()->subSecond(1),
+        ]);
+
+        $this->asAdmin()->get('/')
+            ->assertElementNotContains('#recently-updated-pages', $page->name);
+
+        $this->put($page->getUrl(), [
+            'name' => $page->name,
+            'html' => $page->html,
+        ]);
+
+        $this->get('/')
+            ->assertElementContains('#recently-updated-pages', $page->name);
     }
-}
\ No newline at end of file
+}
index c9e116523ed0fdf943622fc4c6a77a7d07ceb689..25a0ae7209572b18145e1cf9411c8fff4b2edd93 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Tools\SearchOptions;
 use Tests\TestCase;
@@ -18,7 +20,7 @@ class SearchOptionsTest extends TestCase
     public function test_to_string_includes_all_items_in_the_correct_format()
     {
         $expected = 'cat "dog" [tag=good] {is_tree}';
-        $options = new SearchOptions;
+        $options = new SearchOptions();
         $options->searches = ['cat'];
         $options->exacts = ['dog'];
         $options->tags = ['tag=good'];
@@ -36,8 +38,8 @@ class SearchOptionsTest extends TestCase
 
         $this->assertEquals([
             'is_tree' => '',
-            'name' => 'dan',
-            'cat' => 'happy',
+            'name'    => 'dan',
+            'cat'     => 'happy',
         ], $opts->filters);
     }
 }
index c27c41f29120e83bb7e635568a4c5b98e6b9e261..7b10278b06c8f422015af0630fcf5473a6caaa68 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
@@ -10,7 +12,7 @@ class SortTest extends TestCase
 {
     protected $book;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
         $this->book = Book::first();
@@ -31,17 +33,17 @@ class SortTest extends TestCase
 
     public function test_page_move_into_book()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
 
         $resp = $this->asEditor()->get($page->getUrl('/move'));
         $resp->assertSee('Move Page');
 
         $movePageResp = $this->put($page->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
-        $page = Page::find($page->id);
+        $page = Page::query()->find($page->id);
 
         $movePageResp->assertRedirect($page->getUrl());
         $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
@@ -53,15 +55,15 @@ class SortTest extends TestCase
 
     public function test_page_move_into_chapter()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
         $newChapter = $newBook->chapters()->first();
 
         $movePageResp = $this->actingAs($this->getEditor())->put($page->getUrl('/move'), [
-            'entity_selection' => 'chapter:' . $newChapter->id
+            'entity_selection' => 'chapter:' . $newChapter->id,
         ]);
-        $page = Page::find($page->id);
+        $page = Page::query()->find($page->id);
 
         $movePageResp->assertRedirect($page->getUrl());
         $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new chapter');
@@ -72,12 +74,12 @@ class SortTest extends TestCase
 
     public function test_page_move_from_chapter_to_book()
     {
-        $oldChapter = Chapter::first();
+        $oldChapter = Chapter::query()->first();
         $page = $oldChapter->pages()->first();
-        $newBook = Book::where('id', '!=', $oldChapter->book_id)->first();
+        $newBook = Book::query()->where('id', '!=', $oldChapter->book_id)->first();
 
         $movePageResp = $this->actingAs($this->getEditor())->put($page->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
         $page->refresh();
 
@@ -99,16 +101,16 @@ class SortTest extends TestCase
         $this->setEntityRestrictions($newBook, ['view', 'update', 'delete'], $editor->roles->all());
 
         $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
         $this->assertPermissionError($movePageResp);
 
         $this->setEntityRestrictions($newBook, ['view', 'update', 'delete', 'create'], $editor->roles->all());
         $movePageResp = $this->put($page->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
 
-        $page = Page::find($page->id);
+        $page = Page::query()->find($page->id);
         $movePageResp->assertRedirect($page->getUrl());
 
         $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
@@ -116,16 +118,16 @@ class SortTest extends TestCase
 
     public function test_page_move_requires_delete_permissions()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
         $editor = $this->getEditor();
 
         $this->setEntityRestrictions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all());
         $this->setEntityRestrictions($page, ['view', 'update', 'create'], $editor->roles->all());
 
         $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
         $this->assertPermissionError($movePageResp);
         $pageView = $this->get($page->getUrl());
@@ -133,29 +135,29 @@ class SortTest extends TestCase
 
         $this->setEntityRestrictions($page, ['view', 'update', 'create', 'delete'], $editor->roles->all());
         $movePageResp = $this->put($page->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
 
-        $page = Page::find($page->id);
+        $page = Page::query()->find($page->id);
         $movePageResp->assertRedirect($page->getUrl());
         $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
     }
 
     public function test_chapter_move()
     {
-        $chapter = Chapter::first();
+        $chapter = Chapter::query()->first();
         $currentBook = $chapter->book;
         $pageToCheck = $chapter->pages->first();
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
 
         $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move'));
         $chapterMoveResp->assertSee('Move Chapter');
 
         $moveChapterResp = $this->put($chapter->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
 
-        $chapter = Chapter::find($chapter->id);
+        $chapter = Chapter::query()->find($chapter->id);
         $moveChapterResp->assertRedirect($chapter->getUrl());
         $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
 
@@ -163,7 +165,7 @@ class SortTest extends TestCase
         $newBookResp->assertSee('moved chapter');
         $newBookResp->assertSee($chapter->name);
 
-        $pageToCheck = Page::find($pageToCheck->id);
+        $pageToCheck = Page::query()->find($pageToCheck->id);
         $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
         $pageCheckResp = $this->get($pageToCheck->getUrl());
         $pageCheckResp->assertSee($newBook->name);
@@ -171,16 +173,16 @@ class SortTest extends TestCase
 
     public function test_chapter_move_requires_delete_permissions()
     {
-        $chapter = Chapter::first();
+        $chapter = Chapter::query()->first();
         $currentBook = $chapter->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
         $editor = $this->getEditor();
 
         $this->setEntityRestrictions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles->all());
         $this->setEntityRestrictions($chapter, ['view', 'update', 'create'], $editor->roles->all());
 
         $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
         $this->assertPermissionError($moveChapterResp);
         $pageView = $this->get($chapter->getUrl());
@@ -188,10 +190,35 @@ class SortTest extends TestCase
 
         $this->setEntityRestrictions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles->all());
         $moveChapterResp = $this->put($chapter->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
 
-        $chapter = Chapter::find($chapter->id);
+        $chapter = Chapter::query()->find($chapter->id);
+        $moveChapterResp->assertRedirect($chapter->getUrl());
+        $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
+    }
+
+    public function test_chapter_move_requires_create_permissions_in_new_book()
+    {
+        $chapter = Chapter::query()->first();
+        $currentBook = $chapter->book;
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
+        $editor = $this->getEditor();
+
+        $this->setEntityRestrictions($newBook, ['view', 'update', 'delete'], [$editor->roles->first()]);
+        $this->setEntityRestrictions($chapter, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]);
+
+        $moveChapterResp = $this->actingAs($editor)->put($chapter->getUrl('/move'), [
+            'entity_selection' => 'book:' . $newBook->id,
+        ]);
+        $this->assertPermissionError($moveChapterResp);
+
+        $this->setEntityRestrictions($newBook, ['view', 'update', 'create', 'delete'], [$editor->roles->first()]);
+        $moveChapterResp = $this->put($chapter->getUrl('/move'), [
+            'entity_selection' => 'book:' . $newBook->id,
+        ]);
+
+        $chapter = Chapter::query()->find($chapter->id);
         $moveChapterResp->assertRedirect($chapter->getUrl());
         $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
     }
@@ -207,13 +234,26 @@ class SortTest extends TestCase
         $pageToCheck->delete();
 
         $this->asEditor()->put($chapter->getUrl('/move'), [
-            'entity_selection' => 'book:' . $newBook->id
+            'entity_selection' => 'book:' . $newBook->id,
         ]);
 
         $pageToCheck->refresh();
         $this->assertEquals($newBook->id, $pageToCheck->book_id);
     }
 
+    public function test_book_sort_page_shows()
+    {
+        /** @var Book $bookToSort */
+        $bookToSort = Book::query()->first();
+
+        $resp = $this->asAdmin()->get($bookToSort->getUrl());
+        $resp->assertElementExists('a[href="' . $bookToSort->getUrl('/sort') . '"]');
+
+        $resp = $this->get($bookToSort->getUrl('/sort'));
+        $resp->assertStatus(200);
+        $resp->assertSee($bookToSort->name);
+    }
+
     public function test_book_sort()
     {
         $oldBook = Book::query()->first();
@@ -224,20 +264,20 @@ class SortTest extends TestCase
         // Create request data
         $reqData = [
             [
-                'id' => $chapterToMove->id,
-                'sort' => 0,
+                'id'            => $chapterToMove->id,
+                'sort'          => 0,
                 'parentChapter' => false,
-                'type' => 'chapter',
-                'book' => $newBook->id
-            ]
+                'type'          => 'chapter',
+                'book'          => $newBook->id,
+            ],
         ];
         foreach ($pagesToMove as $index => $page) {
             $reqData[] = [
-                'id' => $page->id,
-                'sort' => $index,
+                'id'            => $page->id,
+                'sort'          => $index,
                 'parentChapter' => $index === count($pagesToMove) - 1 ? $chapterToMove->id : false,
-                'type' => 'page',
-                'book' => $newBook->id
+                'type'          => 'page',
+                'book'          => $newBook->id,
             ];
         }
 
@@ -245,16 +285,185 @@ class SortTest extends TestCase
         $sortResp->assertRedirect($newBook->getUrl());
         $sortResp->assertStatus(302);
         $this->assertDatabaseHas('chapters', [
-            'id' => $chapterToMove->id,
-            'book_id' => $newBook->id,
-            'priority' => 0
+            'id'       => $chapterToMove->id,
+            'book_id'  => $newBook->id,
+            'priority' => 0,
         ]);
         $this->assertTrue($newBook->chapters()->count() === 1);
         $this->assertTrue($newBook->chapters()->first()->pages()->count() === 1);
 
         $checkPage = $pagesToMove[1];
-        $checkResp = $this->get(Page::find($checkPage->id)->getUrl());
+        $checkResp = $this->get($checkPage->refresh()->getUrl());
         $checkResp->assertSee($newBook->name);
     }
 
-}
\ No newline at end of file
+    public function test_book_sort_makes_no_changes_if_new_chapter_does_not_align_with_new_book()
+    {
+        /** @var Page $page */
+        $page = Page::query()->where('chapter_id', '!=', 0)->first();
+        $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first();
+
+        $sortData = [
+            'id'            => $page->id,
+            'sort'          => 0,
+            'parentChapter' => $otherChapter->id,
+            'type'          => 'page',
+            'book'          => $page->book_id,
+        ];
+        $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
+        ]);
+    }
+
+    public function test_book_sort_makes_no_changes_if_no_view_permissions_on_new_chapter()
+    {
+        /** @var Page $page */
+        $page = Page::query()->where('chapter_id', '!=', 0)->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first();
+        $this->setEntityRestrictions($otherChapter);
+
+        $sortData = [
+            'id'            => $page->id,
+            'sort'          => 0,
+            'parentChapter' => $otherChapter->id,
+            'type'          => 'page',
+            'book'          => $otherChapter->book_id,
+        ];
+        $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
+        ]);
+    }
+
+    public function test_book_sort_makes_no_changes_if_no_view_permissions_on_new_book()
+    {
+        /** @var Page $page */
+        $page = Page::query()->where('chapter_id', '!=', 0)->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first();
+        $editor = $this->getEditor();
+        $this->setEntityRestrictions($otherChapter->book, ['update', 'delete'], [$editor->roles()->first()]);
+
+        $sortData = [
+            'id'            => $page->id,
+            'sort'          => 0,
+            'parentChapter' => $otherChapter->id,
+            'type'          => 'page',
+            'book'          => $otherChapter->book_id,
+        ];
+        $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
+        ]);
+    }
+
+    public function test_book_sort_makes_no_changes_if_no_update_or_create_permissions_on_new_chapter()
+    {
+        /** @var Page $page */
+        $page = Page::query()->where('chapter_id', '!=', 0)->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first();
+        $editor = $this->getEditor();
+        $this->setEntityRestrictions($otherChapter, ['view', 'delete'], [$editor->roles()->first()]);
+
+        $sortData = [
+            'id'            => $page->id,
+            'sort'          => 0,
+            'parentChapter' => $otherChapter->id,
+            'type'          => 'page',
+            'book'          => $otherChapter->book_id,
+        ];
+        $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
+        ]);
+    }
+
+    public function test_book_sort_makes_no_changes_if_no_update_permissions_on_moved_item()
+    {
+        /** @var Page $page */
+        $page = Page::query()->where('chapter_id', '!=', 0)->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first();
+        $editor = $this->getEditor();
+        $this->setEntityRestrictions($page, ['view', 'delete'], [$editor->roles()->first()]);
+
+        $sortData = [
+            'id'            => $page->id,
+            'sort'          => 0,
+            'parentChapter' => $otherChapter->id,
+            'type'          => 'page',
+            'book'          => $otherChapter->book_id,
+        ];
+        $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
+        ]);
+    }
+
+    public function test_book_sort_makes_no_changes_if_no_delete_permissions_on_moved_item()
+    {
+        /** @var Page $page */
+        $page = Page::query()->where('chapter_id', '!=', 0)->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->where('book_id', '!=', $page->book_id)->first();
+        $editor = $this->getEditor();
+        $this->setEntityRestrictions($page, ['view', 'update'], [$editor->roles()->first()]);
+
+        $sortData = [
+            'id'            => $page->id,
+            'sort'          => 0,
+            'parentChapter' => $otherChapter->id,
+            'type'          => 'page',
+            'book'          => $otherChapter->book_id,
+        ];
+        $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id,
+        ]);
+    }
+
+    public function test_book_sort_item_returns_book_content()
+    {
+        $books = Book::all();
+        $bookToSort = $books[0];
+        $firstPage = $bookToSort->pages[0];
+        $firstChapter = $bookToSort->chapters[0];
+
+        $resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item');
+
+        // Ensure book details are returned
+        $resp->assertSee($bookToSort->name);
+        $resp->assertSee($firstPage->name);
+        $resp->assertSee($firstChapter->name);
+    }
+
+    public function test_pages_in_book_show_sorted_by_priority()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('pages')->first();
+        $book->chapters()->forceDelete();
+        /** @var Page[] $pages */
+        $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get();
+        $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete();
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertElementContains('.content-wrap a.page:nth-child(1)', $pages[0]->name);
+        $resp->assertElementContains('.content-wrap a.page:nth-child(2)', $pages[1]->name);
+
+        $pages[0]->forceFill(['priority' => 10])->save();
+        $pages[1]->forceFill(['priority' => 5])->save();
+
+        $resp = $this->asEditor()->get($book->getUrl());
+        $resp->assertElementContains('.content-wrap a.page:nth-child(1)', $pages[1]->name);
+        $resp->assertElementContains('.content-wrap a.page:nth-child(2)', $pages[0]->name);
+    }
+}
index 3ad10641ef3d0196ce15800d18d0ed149e3ed50a..160dd62d8181c7f08a361502295755b47da02e23 100644 (file)
@@ -1,32 +1,30 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
-use BookStack\Entities\Models\Book;
-use BookStack\Entities\Models\Chapter;
 use BookStack\Actions\Tag;
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
-use BookStack\Auth\Permissions\PermissionService;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class TagTest extends BrowserKitTest
+class TagTest extends TestCase
 {
-
     protected $defaultTagCount = 20;
 
     /**
      * Get an instance of a page that has many tags.
-     * @param \BookStack\Actions\Tag[]|bool $tags
-     * @return Entity
      */
-    protected function getEntityWithTags($class, $tags = false): Entity
+    protected function getEntityWithTags($class, ?array $tags = null): Entity
     {
         $entity = $class::first();
 
-        if (!$tags) {
-            $tags = factory(Tag::class, $this->defaultTagCount)->make();
+        if (is_null($tags)) {
+            $tags = Tag::factory()->count($this->defaultTagCount)->make();
         }
 
         $entity->tags()->saveMany($tags);
+
         return $entity;
     }
 
@@ -34,58 +32,174 @@ class TagTest extends BrowserKitTest
     {
         // Create some tags with similar names to test with
         $attrs = collect();
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans']));
-        $page = $this->getEntityWithTags(Page::class, $attrs);
-
-        $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]);
-        $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']);
-        $this->get('/ajax/tags/suggest/names?search=cou')->seeJsonEquals(['country', 'county']);
-        $this->get('/ajax/tags/suggest/names?search=pla')->seeJsonEquals(['planet', 'plans']);
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'country']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'color']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'city']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'county']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'planet']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'plans']));
+        $page = $this->getEntityWithTags(Page::class, $attrs->all());
+
+        $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->assertSimilarJson([]);
+        $this->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country', 'county']);
+        $this->get('/ajax/tags/suggest/names?search=cou')->assertSimilarJson(['country', 'county']);
+        $this->get('/ajax/tags/suggest/names?search=pla')->assertSimilarJson(['planet', 'plans']);
     }
 
     public function test_tag_value_suggestions()
     {
         // Create some tags with similar values to test with
         $attrs = collect();
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country', 'value' => 'cats']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color', 'value' => 'cattery']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'city', 'value' => 'castle']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy']));
-        $page = $this->getEntityWithTags(Page::class, $attrs);
-
-        $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]);
-        $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']);
-        $this->get('/ajax/tags/suggest/values?search=do')->seeJsonEquals(['dog', 'dodgy']);
-        $this->get('/ajax/tags/suggest/values?search=cas')->seeJsonEquals(['castle']);
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'country', 'value' => 'cats']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'color', 'value' => 'cattery']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'city', 'value' => 'castle']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'county', 'value' => 'dog']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'planet', 'value' => 'catapult']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'plans', 'value' => 'dodgy']));
+        $page = $this->getEntityWithTags(Page::class, $attrs->all());
+
+        $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->assertSimilarJson([]);
+        $this->get('/ajax/tags/suggest/values?search=cat')->assertSimilarJson(['cats', 'cattery', 'catapult']);
+        $this->get('/ajax/tags/suggest/values?search=do')->assertSimilarJson(['dog', 'dodgy']);
+        $this->get('/ajax/tags/suggest/values?search=cas')->assertSimilarJson(['castle']);
     }
 
     public function test_entity_permissions_effect_tag_suggestions()
     {
-        $permissionService = $this->app->make(PermissionService::class);
-
         // Create some tags with similar names to test with and save to a page
         $attrs = collect();
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country']));
-        $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color']));
-        $page = $this->getEntityWithTags(Page::class, $attrs);
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'country']));
+        $attrs = $attrs->merge(Tag::factory()->count(5)->make(['name' => 'color']));
+        $page = $this->getEntityWithTags(Page::class, $attrs->all());
 
-        $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']);
-        $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']);
+        $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']);
+        $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']);
 
         // Set restricted permission the page
         $page->restricted = true;
         $page->save();
         $page->rebuildPermissions();
 
-        $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']);
-        $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals([]);
+        $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson(['color', 'country']);
+        $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertSimilarJson([]);
+    }
+
+    public function test_tags_shown_on_search_listing()
+    {
+        $tags = [
+            Tag::factory()->make(['name' => 'category', 'value' => 'buckets']),
+            Tag::factory()->make(['name' => 'color', 'value' => 'red']),
+        ];
+
+        $page = $this->getEntityWithTags(Page::class, $tags);
+        $resp = $this->asEditor()->get('/search?term=[category]');
+        $resp->assertSee($page->name);
+        $resp->assertElementContains('[href="' . $page->getUrl() . '"]', 'category');
+        $resp->assertElementContains('[href="' . $page->getUrl() . '"]', 'buckets');
+        $resp->assertElementContains('[href="' . $page->getUrl() . '"]', 'color');
+        $resp->assertElementContains('[href="' . $page->getUrl() . '"]', 'red');
     }
 
+    public function test_tags_index_shows_tag_name_as_expected_with_right_counts()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']);
+        $page->tags()->create(['name' => 'Category', 'value' => 'OtherTestContent']);
+
+        $resp = $this->asEditor()->get('/tags');
+        $resp->assertSee('Category');
+        $resp->assertElementCount('.tag-item', 1);
+        $resp->assertDontSee('GreatTestContent');
+        $resp->assertDontSee('OtherTestContent');
+        $resp->assertElementContains('a[title="Total tag usages"]', '2');
+        $resp->assertElementContains('a[title="Assigned to Pages"]', '2');
+        $resp->assertElementContains('a[title="Assigned to Books"]', '0');
+        $resp->assertElementContains('a[title="Assigned to Chapters"]', '0');
+        $resp->assertElementContains('a[title="Assigned to Shelves"]', '0');
+        $resp->assertElementContains('a[href$="/tags?name=Category"]', '2 unique values');
+
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $book->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']);
+        $resp = $this->asEditor()->get('/tags');
+        $resp->assertElementContains('a[title="Total tag usages"]', '3');
+        $resp->assertElementContains('a[title="Assigned to Books"]', '1');
+        $resp->assertElementContains('a[href$="/tags?name=Category"]', '2 unique values');
+    }
+
+    public function test_tag_index_can_be_searched()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']);
+
+        $resp = $this->asEditor()->get('/tags?search=cat');
+        $resp->assertElementContains('.tag-item .tag-name', 'Category');
+
+        $resp = $this->asEditor()->get('/tags?search=content');
+        $resp->assertElementContains('.tag-item .tag-name', 'Category');
+        $resp->assertElementContains('.tag-item .tag-value', 'GreatTestContent');
+
+        $resp = $this->asEditor()->get('/tags?search=other');
+        $resp->assertElementNotExists('.tag-item .tag-name');
+    }
+
+    public function test_tag_index_search_will_show_mulitple_values_of_a_single_tag_name()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->tags()->create(['name' => 'Animal', 'value' => 'Catfish']);
+        $page->tags()->create(['name' => 'Animal', 'value' => 'Catdog']);
+
+        $resp = $this->asEditor()->get('/tags?search=cat');
+        $resp->assertElementContains('.tag-item .tag-value', 'Catfish');
+        $resp->assertElementContains('.tag-item .tag-value', 'Catdog');
+    }
+
+    public function test_tag_index_can_be_scoped_to_specific_tag_name()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']);
+        $page->tags()->create(['name' => 'Category', 'value' => 'OtherTestContent']);
+        $page->tags()->create(['name' => 'OtherTagName', 'value' => 'OtherValue']);
+
+        $resp = $this->asEditor()->get('/tags?name=Category');
+        $resp->assertSee('Category');
+        $resp->assertSee('GreatTestContent');
+        $resp->assertSee('OtherTestContent');
+        $resp->assertDontSee('OtherTagName');
+        $resp->assertElementCount('table .tag-item', 2);
+        $resp->assertSee('Active Filter:');
+        $resp->assertElementContains('form[action$="/tags"]', 'Clear Filter');
+    }
+
+    public function test_tags_index_adheres_to_page_permissions()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->tags()->create(['name' => 'SuperCategory', 'value' => 'GreatTestContent']);
+
+        $resp = $this->asEditor()->get('/tags');
+        $resp->assertSee('SuperCategory');
+        $resp = $this->get('/tags?name=SuperCategory');
+        $resp->assertSee('GreatTestContent');
+
+        $page->restricted = true;
+        $this->regenEntityPermissions($page);
+
+        $resp = $this->asEditor()->get('/tags');
+        $resp->assertDontSee('SuperCategory');
+        $resp = $this->get('/tags?name=SuperCategory');
+        $resp->assertDontSee('GreatTestContent');
+    }
+
+    public function test_tag_index_shows_message_on_no_results()
+    {
+        /** @var Page $page */
+        $resp = $this->asEditor()->get('/tags?search=testingval');
+        $resp->assertSee('No items available');
+        $resp->assertSee('Tags can be assigned via the page editor sidebar');
+    }
 }
index 1558df78d1c200d59b29d559ceef38f687b81e4f..2eeb6537e0e0b9afaf5823ad99a66e2eab7a1322 100644 (file)
@@ -1,11 +1,12 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 use BookStack\Entities\Models\Book;
 use Illuminate\Support\Facades\Log;
 
 class ErrorTest extends TestCase
 {
-
     public function test_404_page_does_not_show_login()
     {
         // Due to middleware being handled differently this will not fail
@@ -38,4 +39,11 @@ class ErrorTest extends TestCase
 
         $this->assertCount(1, $handler->getRecords());
     }
-}
\ No newline at end of file
+
+    public function test_access_to_non_existing_image_location_provides_404_response()
+    {
+        $resp = $this->actingAs($this->getViewer())->get('/uploads/images/gallery/2021-05/anonexistingimage.png');
+        $resp->assertStatus(404);
+        $resp->assertSeeText('Image Not Found');
+    }
+}
diff --git a/tests/FavouriteTest.php b/tests/FavouriteTest.php
new file mode 100644 (file)
index 0000000..a0f1188
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+
+use BookStack\Actions\Favourite;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class FavouriteTest extends TestCase
+{
+    public function test_page_add_favourite_flow()
+    {
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertElementContains('button', 'Favourite');
+        $resp->assertElementExists('form[method="POST"][action$="/favourites/add"]');
+
+        $resp = $this->post('/favourites/add', [
+            'type' => get_class($page),
+            'id'   => $page->id,
+        ]);
+        $resp->assertRedirect($page->getUrl());
+        $resp->assertSessionHas('success', "\"{$page->name}\" has been added to your favourites");
+
+        $this->assertDatabaseHas('favourites', [
+            'user_id'           => $editor->id,
+            'favouritable_type' => $page->getMorphClass(),
+            'favouritable_id'   => $page->id,
+        ]);
+    }
+
+    public function test_page_remove_favourite_flow()
+    {
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+        Favourite::query()->forceCreate([
+            'user_id'           => $editor->id,
+            'favouritable_id'   => $page->id,
+            'favouritable_type' => $page->getMorphClass(),
+        ]);
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertElementContains('button', 'Unfavourite');
+        $resp->assertElementExists('form[method="POST"][action$="/favourites/remove"]');
+
+        $resp = $this->post('/favourites/remove', [
+            'type' => get_class($page),
+            'id'   => $page->id,
+        ]);
+        $resp->assertRedirect($page->getUrl());
+        $resp->assertSessionHas('success', "\"{$page->name}\" has been removed from your favourites");
+
+        $this->assertDatabaseMissing('favourites', [
+            'user_id' => $editor->id,
+        ]);
+    }
+
+    public function test_book_chapter_shelf_pages_contain_favourite_button()
+    {
+        $entities = [
+            Bookshelf::query()->first(),
+            Book::query()->first(),
+            Chapter::query()->first(),
+        ];
+        $this->actingAs($this->getEditor());
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertElementExists('form[method="POST"][action$="/favourites/add"]');
+        }
+    }
+
+    public function test_header_contains_link_to_favourites_page_when_logged_in()
+    {
+        $this->setSettings(['app-public' => 'true']);
+        $this->get('/')->assertElementNotContains('header', 'My Favourites');
+        $this->actingAs($this->getViewer())->get('/')->assertElementContains('header a', 'My Favourites');
+    }
+
+    public function test_favourites_shown_on_homepage()
+    {
+        $editor = $this->getEditor();
+
+        $resp = $this->actingAs($editor)->get('/');
+        $resp->assertElementNotExists('#top-favourites');
+
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $page->favourites()->save((new Favourite())->forceFill(['user_id' => $editor->id]));
+
+        $resp = $this->get('/');
+        $resp->assertElementExists('#top-favourites');
+        $resp->assertElementContains('#top-favourites', $page->name);
+    }
+
+    public function test_favourites_list_page_shows_favourites_and_has_working_pagination()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+
+        $resp = $this->actingAs($editor)->get('/favourites');
+        $resp->assertDontSee($page->name);
+
+        $page->favourites()->save((new Favourite())->forceFill(['user_id' => $editor->id]));
+
+        $resp = $this->get('/favourites');
+        $resp->assertSee($page->name);
+
+        $resp = $this->get('/favourites?page=2');
+        $resp->assertDontSee($page->name);
+    }
+}
diff --git a/tests/Helpers/OidcJwtHelper.php b/tests/Helpers/OidcJwtHelper.php
new file mode 100644 (file)
index 0000000..55a34d4
--- /dev/null
@@ -0,0 +1,133 @@
+<?php
+
+namespace Tests\Helpers;
+
+use phpseclib3\Crypt\RSA;
+
+/**
+ * A collection of functions to help with OIDC JWT testing.
+ * By default, unless overridden, content is provided in a correct working state.
+ */
+class OidcJwtHelper
+{
+    public static function defaultIssuer(): string
+    {
+        return 'https://auth.example.com';
+    }
+
+    public static function defaultClientId(): string
+    {
+        return 'xxyyzz.aaa.bbccdd.123';
+    }
+
+    public static function defaultPayload(): array
+    {
+        return [
+            'sub'                => 'abc1234def',
+            'name'               => 'Barry Scott',
+            'email'              => '[email protected]',
+            'ver'                => 1,
+            'iss'                => static::defaultIssuer(),
+            'aud'                => static::defaultClientId(),
+            'iat'                => time(),
+            'exp'                => time() + 720,
+            'jti'                => 'ID.AaaBBBbbCCCcccDDddddddEEEeeeeee',
+            'amr'                => ['pwd'],
+            'idp'                => 'fghfghgfh546456dfgdfg',
+            'preferred_username' => 'xXBazzaXx',
+            'auth_time'          => time(),
+            'at_hash'            => 'sT4jbsdSGy9w12pq3iNYDA',
+        ];
+    }
+
+    public static function idToken($payloadOverrides = [], $headerOverrides = []): string
+    {
+        $payload = array_merge(static::defaultPayload(), $payloadOverrides);
+        $header = array_merge([
+            'kid' => 'xyz456',
+            'alg' => 'RS256',
+        ], $headerOverrides);
+
+        $top = implode('.', [
+            static::base64UrlEncode(json_encode($header)),
+            static::base64UrlEncode(json_encode($payload)),
+        ]);
+
+        $privateKey = static::privateKeyInstance();
+        $signature = $privateKey->sign($top);
+
+        return $top . '.' . static::base64UrlEncode($signature);
+    }
+
+    public static function privateKeyInstance()
+    {
+        static $key;
+        if (is_null($key)) {
+            $key = RSA::loadPrivateKey(static::privatePemKey())->withPadding(RSA::SIGNATURE_PKCS1);
+        }
+
+        return $key;
+    }
+
+    public static function base64UrlEncode(string $decoded): string
+    {
+        return strtr(base64_encode($decoded), '+/', '-_');
+    }
+
+    public static function publicPemKey(): string
+    {
+        return '-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9
+DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm
+zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i
+iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl
++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk
+WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw
+3wIDAQAB
+-----END PUBLIC KEY-----';
+    }
+
+    public static function privatePemKey(): string
+    {
+        return '-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb
+NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te
+g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC
+xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp
+06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT
+dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6
+sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ
+6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr
+4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF
+v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW
+fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv
+HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70
+SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf
+z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s
+HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA
+DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh
+ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y
+uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5
+K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi
+6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs
+IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd
+W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7
+9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf
+efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII
+ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl
+q/1PY4iJviGKddtmfClH3v4=
+-----END PRIVATE KEY-----';
+    }
+
+    public static function publicJwkKeyArray(): array
+    {
+        return [
+            'kty' => 'RSA',
+            'alg' => 'RS256',
+            'kid' => '066e52af-8884-4926-801d-032a276f9f2a',
+            'use' => 'sig',
+            'e'   => 'AQAB',
+            'n'   => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w',
+        ];
+    }
+}
index a8e33465d108b1b9ad5000ef796ce761789b3db4..900650a70537eaddd6da5b4d68a8d28dc91b1155 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
@@ -7,7 +9,6 @@ use BookStack\Entities\Models\Page;
 
 class HomepageTest extends TestCase
 {
-
     public function test_default_homepage_visible()
     {
         $this->asEditor();
@@ -42,8 +43,8 @@ class HomepageTest extends TestCase
         $content = str_repeat('This is the body content of my custom homepage.', 20);
         $customPage = $this->newPage(['name' => $name, 'html' => $content]);
         $this->setSettings([
-            'app-homepage' => $customPage->id,
-            'app-homepage-type' => 'page'
+            'app-homepage'      => $customPage->id,
+            'app-homepage-type' => 'page',
         ]);
 
         $homeVisit = $this->get('/');
@@ -68,8 +69,8 @@ class HomepageTest extends TestCase
         $content = str_repeat('This is the body content of my custom homepage.', 20);
         $customPage = $this->newPage(['name' => $name, 'html' => $content]);
         $this->setSettings([
-            'app-homepage' => $customPage->id,
-            'app-homepage-type' => 'default'
+            'app-homepage'      => $customPage->id,
+            'app-homepage-type' => 'default',
         ]);
 
         $pageDeleteReq = $this->delete($customPage->getUrl());
@@ -78,6 +79,43 @@ class HomepageTest extends TestCase
         $pageDeleteReq->assertSessionMissing('error');
     }
 
+    public function test_custom_homepage_cannot_be_deleted_from_parent_deletion()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $this->setSettings([
+            'app-homepage'      => $page->id,
+            'app-homepage-type' => 'page',
+        ]);
+
+        $this->asEditor()->delete($page->book->getUrl());
+        $this->assertSessionError('Cannot delete a page while it is set as a homepage');
+        $this->assertDatabaseMissing('deletions', ['deletable_id' => $page->book->id]);
+
+        $page->refresh();
+        $this->assertNull($page->deleted_at);
+        $this->assertNull($page->book->deleted_at);
+    }
+
+    public function test_custom_homepage_renders_includes()
+    {
+        $this->asEditor();
+        /** @var Page $included */
+        $included = Page::query()->first();
+        $content = str_repeat('This is the body content of my custom homepage.', 20);
+        $included->html = $content;
+        $included->save();
+
+        $name = 'My custom homepage';
+        $customPage = $this->newPage(['name' => $name, 'html' => '{{@' . $included->id . '}}']);
+        $this->setSettings(['app-homepage' => $customPage->id]);
+        $this->setSettings(['app-homepage-type' => 'page']);
+
+        $homeVisit = $this->get('/');
+        $homeVisit->assertSee($name);
+        $homeVisit->assertSee($content);
+    }
+
     public function test_set_book_homepage()
     {
         $editor = $this->getEditor();
@@ -147,7 +185,7 @@ class HomepageTest extends TestCase
 
     public function test_new_users_dont_have_any_recently_viewed()
     {
-        $user = factory(User::class)->create();
+        $user = User::factory()->create();
         $viewRole = Role::getRole('Viewer');
         $user->attachRole($viewRole);
 
index d5c6e453238ead0b3d82f0d805d23f7983fdf676..ef44af0eebd05b2a0998ded181454932e9cf61f6 100644 (file)
@@ -1,14 +1,15 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 class LanguageTest extends TestCase
 {
-
     protected $langs;
 
     /**
      * LanguageTest constructor.
      */
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
         $this->langs = array_diff(scandir(resource_path('lang')), ['..', '.']);
@@ -61,6 +62,7 @@ class LanguageTest extends TestCase
         foreach ($this->langs as $lang) {
             foreach ($files as $file) {
                 $loadError = false;
+
                 try {
                     $translations = trans(str_replace('.php', '', $file), [], $lang);
                 } catch (\Exception $e) {
@@ -74,10 +76,9 @@ class LanguageTest extends TestCase
     public function test_rtl_config_set_if_lang_is_rtl()
     {
         $this->asEditor();
-        $this->assertFalse(config('app.rtl'), "App RTL config should be false by default");
+        $this->assertFalse(config('app.rtl'), 'App RTL config should be false by default');
         setting()->putUser($this->getEditor(), 'language', 'ar');
         $this->get('/');
-        $this->assertTrue(config('app.rtl'), "App RTL config should have been set to true by middleware");
+        $this->assertTrue(config('app.rtl'), 'App RTL config should have been set to true by middleware');
     }
-
-}
\ No newline at end of file
+}
diff --git a/tests/OpenGraphTest.php b/tests/OpenGraphTest.php
new file mode 100644 (file)
index 0000000..17a5aa2
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+
+namespace Tests;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Repos\BookshelfRepo;
+use Illuminate\Support\Str;
+use Tests\Uploads\UsesImages;
+
+class OpenGraphTest extends TestCase
+{
+    use UsesImages;
+
+    public function test_page_tags()
+    {
+        $page = Page::query()->first();
+        $resp = $this->asEditor()->get($page->getUrl());
+        $tags = $this->getOpenGraphTags($resp);
+
+        $this->assertEquals($page->getShortName() . ' | BookStack', $tags['title']);
+        $this->assertEquals($page->getUrl(), $tags['url']);
+        $this->assertEquals(Str::limit($page->text, 100, '...'), $tags['description']);
+    }
+
+    public function test_chapter_tags()
+    {
+        $chapter = Chapter::query()->first();
+        $resp = $this->asEditor()->get($chapter->getUrl());
+        $tags = $this->getOpenGraphTags($resp);
+
+        $this->assertEquals($chapter->getShortName() . ' | BookStack', $tags['title']);
+        $this->assertEquals($chapter->getUrl(), $tags['url']);
+        $this->assertEquals(Str::limit($chapter->description, 100, '...'), $tags['description']);
+    }
+
+    public function test_book_tags()
+    {
+        $book = Book::query()->first();
+        $resp = $this->asEditor()->get($book->getUrl());
+        $tags = $this->getOpenGraphTags($resp);
+
+        $this->assertEquals($book->getShortName() . ' | BookStack', $tags['title']);
+        $this->assertEquals($book->getUrl(), $tags['url']);
+        $this->assertEquals(Str::limit($book->description, 100, '...'), $tags['description']);
+        $this->assertArrayNotHasKey('image', $tags);
+
+        // Test image set if image has cover image
+        $bookRepo = app(BookRepo::class);
+        $bookRepo->updateCoverImage($book, $this->getTestImage('image.png'));
+        $resp = $this->asEditor()->get($book->getUrl());
+        $tags = $this->getOpenGraphTags($resp);
+
+        $this->assertEquals($book->getBookCover(), $tags['image']);
+    }
+
+    public function test_shelf_tags()
+    {
+        $shelf = Bookshelf::query()->first();
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $tags = $this->getOpenGraphTags($resp);
+
+        $this->assertEquals($shelf->getShortName() . ' | BookStack', $tags['title']);
+        $this->assertEquals($shelf->getUrl(), $tags['url']);
+        $this->assertEquals(Str::limit($shelf->description, 100, '...'), $tags['description']);
+        $this->assertArrayNotHasKey('image', $tags);
+
+        // Test image set if image has cover image
+        $shelfRepo = app(BookshelfRepo::class);
+        $shelfRepo->updateCoverImage($shelf, $this->getTestImage('image.png'));
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $tags = $this->getOpenGraphTags($resp);
+
+        $this->assertEquals($shelf->getBookCover(), $tags['image']);
+    }
+
+    /**
+     * Parse the open graph tags from a test response.
+     */
+    protected function getOpenGraphTags(TestResponse $resp): array
+    {
+        $tags = [];
+
+        libxml_use_internal_errors(true);
+        $doc = new \DOMDocument();
+        $doc->loadHTML($resp->getContent());
+        $metaElems = $doc->getElementsByTagName('meta');
+        /** @var \DOMElement $elem */
+        foreach ($metaElems as $elem) {
+            $prop = $elem->getAttribute('property');
+            $name = explode(':', $prop)[1] ?? null;
+            if ($name) {
+                $tags[$name] = $elem->getAttribute('content');
+            }
+        }
+
+        return $tags;
+    }
+}
index 2f06bff2e8e766cb360826ec436b2321a55715f9..fe508668eae6fb660504dd3684d882a289753b8a 100644 (file)
@@ -1,16 +1,16 @@
-<?php namespace Tests\Permissions;
+<?php
+
+namespace Tests\Permissions;
 
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
-use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class EntityOwnerChangeTest extends TestCase
 {
-
     public function test_changing_page_owner()
     {
         $page = Page::query()->first();
@@ -46,5 +46,4 @@ class EntityOwnerChangeTest extends TestCase
         $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]);
         $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]);
     }
-
-}
\ No newline at end of file
+}
index 8dc112e57d8f8182775c4bcc8d60ff3a65e43b12..abd5065f50a244902bc1cd1f0a7080245a836dc6 100644 (file)
@@ -1,17 +1,18 @@
-<?php namespace Tests\Permissions;
+<?php
 
+namespace Tests\Permissions;
+
+use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
-use BookStack\Auth\User;
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Str;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class EntityPermissionsTest extends BrowserKitTest
+class EntityPermissionsTest extends TestCase
 {
-
     /**
      * @var User
      */
@@ -22,7 +23,7 @@ class EntityPermissionsTest extends BrowserKitTest
      */
     protected $viewer;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
         $this->user = $this->getEditor();
@@ -40,609 +41,598 @@ class EntityPermissionsTest extends BrowserKitTest
 
     public function test_bookshelf_view_restriction()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->user)
-            ->visit($shelf->getUrl())
-            ->seePageIs($shelf->getUrl());
+            ->get($shelf->getUrl())
+            ->assertStatus(200);
 
         $this->setRestrictionsForTestRoles($shelf, []);
 
-        $this->forceVisit($shelf->getUrl())
-            ->see('Bookshelf not found');
+        $this->followingRedirects()->get($shelf->getUrl())
+            ->assertSee('Bookshelf not found');
 
         $this->setRestrictionsForTestRoles($shelf, ['view']);
 
-        $this->visit($shelf->getUrl())
-            ->see($shelf->name);
+        $this->get($shelf->getUrl())
+            ->assertSee($shelf->name);
     }
 
     public function test_bookshelf_update_restriction()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->user)
-            ->visit($shelf->getUrl('/edit'))
-            ->see('Edit Book');
+            ->get($shelf->getUrl('/edit'))
+            ->assertSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->forceVisit($shelf->getUrl('/edit'))
-            ->see('You do not have permission')->seePageIs('/');
+        $resp = $this->get($shelf->getUrl('/edit'))
+            ->assertRedirect('/');
+        $this->followRedirects($resp)->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->visit($shelf->getUrl('/edit'))
-            ->seePageIs($shelf->getUrl('/edit'));
+        $this->get($shelf->getUrl('/edit'))
+            ->assertOk();
     }
 
     public function test_bookshelf_delete_restriction()
     {
-        $shelf = Book::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->user)
-            ->visit($shelf->getUrl('/delete'))
-            ->see('Delete Book');
+            ->get($shelf->getUrl('/delete'))
+            ->assertSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->forceVisit($shelf->getUrl('/delete'))
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($shelf->getUrl('/delete'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->visit($shelf->getUrl('/delete'))
-            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+        $this->get($shelf->getUrl('/delete'))
+            ->assertOk()
+            ->assertSee('Delete Book');
     }
 
     public function test_book_view_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->user)
-            ->visit($bookUrl)
-            ->seePageIs($bookUrl);
+            ->get($bookUrl)
+            ->assertOk();
 
         $this->setRestrictionsForTestRoles($book, []);
 
-        $this->forceVisit($bookUrl)
-            ->see('Book not found');
-        $this->forceVisit($bookPage->getUrl())
-            ->see('Page not found');
-        $this->forceVisit($bookChapter->getUrl())
-            ->see('Chapter not found');
+        $this->followingRedirects()->get($bookUrl)
+            ->assertSee('Book not found');
+        $this->followingRedirects()->get($bookPage->getUrl())
+            ->assertSee('Page not found');
+        $this->followingRedirects()->get($bookChapter->getUrl())
+            ->assertSee('Chapter not found');
 
         $this->setRestrictionsForTestRoles($book, ['view']);
 
-        $this->visit($bookUrl)
-            ->see($book->name);
-        $this->visit($bookPage->getUrl())
-            ->see($bookPage->name);
-        $this->visit($bookChapter->getUrl())
-            ->see($bookChapter->name);
+        $this->get($bookUrl)
+            ->assertSee($book->name);
+        $this->get($bookPage->getUrl())
+            ->assertSee($bookPage->name);
+        $this->get($bookChapter->getUrl())
+            ->assertSee($bookChapter->name);
     }
 
     public function test_book_create_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->viewer)
-            ->visit($bookUrl)
-            ->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+            ->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
         $this->actingAs($this->user)
-            ->visit($bookUrl)
-            ->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
+            ->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete', 'update']);
 
-        $this->forceVisit($bookUrl . '/create-chapter')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($bookUrl)->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+        $this->get($bookUrl . '/create-chapter')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->get($bookUrl . '/create-page')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'create']);
 
-        $this->visit($bookUrl . '/create-chapter')
-            ->type('test chapter', 'name')
-            ->type('test description for chapter', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($bookUrl . '/chapter/test-chapter');
-        $this->visit($bookUrl . '/create-page')
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($bookUrl . '/page/test-page');
-        $this->visit($bookUrl)->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
+        $resp = $this->post($book->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'desc',
+        ]);
+        $resp->assertRedirect($book->getUrl('/chapter/test-chapter'));
+
+        $this->get($book->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test content',
+        ]);
+        $resp->assertRedirect($book->getUrl('/page/test-page'));
+
+        $this->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
     }
 
     public function test_book_update_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->user)
-            ->visit($bookUrl . '/edit')
-            ->see('Edit Book');
+            ->get($bookUrl . '/edit')
+            ->assertSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->forceVisit($bookUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->visit($bookUrl . '/edit')
-            ->seePageIs($bookUrl . '/edit');
-        $this->visit($bookPage->getUrl() . '/edit')
-            ->seePageIs($bookPage->getUrl() . '/edit');
-        $this->visit($bookChapter->getUrl() . '/edit')
-            ->see('Edit Chapter');
+        $this->get($bookUrl . '/edit')->assertOk();
+        $this->get($bookPage->getUrl() . '/edit')->assertOk();
+        $this->get($bookChapter->getUrl() . '/edit')->assertSee('Edit Chapter');
     }
 
     public function test_book_delete_restriction()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
-        $this->actingAs($this->user)
-            ->visit($bookUrl . '/delete')
-            ->see('Delete Book');
+        $this->actingAs($this->user)->get($bookUrl . '/delete')
+            ->assertSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->forceVisit($bookUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->visit($bookUrl . '/delete')
-            ->seePageIs($bookUrl . '/delete')->see('Delete Book');
-        $this->visit($bookPage->getUrl() . '/delete')
-            ->seePageIs($bookPage->getUrl() . '/delete')->see('Delete Page');
-        $this->visit($bookChapter->getUrl() . '/delete')
-            ->see('Delete Chapter');
+        $this->get($bookUrl . '/delete')->assertOk()->assertSee('Delete Book');
+        $this->get($bookPage->getUrl('/delete'))->assertOk()->assertSee('Delete Page');
+        $this->get($bookChapter->getUrl('/delete'))->assertSee('Delete Chapter');
     }
 
     public function test_chapter_view_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $chapterPage = $chapter->pages->first();
 
         $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl)
-            ->seePageIs($chapterUrl);
+        $this->actingAs($this->user)->get($chapterUrl)->assertOk();
 
         $this->setRestrictionsForTestRoles($chapter, []);
 
-        $this->forceVisit($chapterUrl)
-            ->see('Chapter not found');
-        $this->forceVisit($chapterPage->getUrl())
-            ->see('Page not found');
+        $this->followingRedirects()->get($chapterUrl)->assertSee('Chapter not found');
+        $this->followingRedirects()->get($chapterPage->getUrl())->assertSee('Page not found');
 
         $this->setRestrictionsForTestRoles($chapter, ['view']);
 
-        $this->visit($chapterUrl)
-            ->see($chapter->name);
-        $this->visit($chapterPage->getUrl())
-            ->see($chapterPage->name);
+        $this->get($chapterUrl)->assertSee($chapter->name);
+        $this->get($chapterPage->getUrl())->assertSee($chapterPage->name);
     }
 
     public function test_chapter_create_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
 
         $chapterUrl = $chapter->getUrl();
         $this->actingAs($this->user)
-            ->visit($chapterUrl)
-            ->seeInElement('.actions', 'New Page');
+            ->get($chapterUrl)
+            ->assertElementContains('.actions', 'New Page');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'delete', 'update']);
 
-        $this->forceVisit($chapterUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($chapterUrl)->dontSeeInElement('.actions', 'New Page');
+        $this->get($chapterUrl . '/create-page')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($chapterUrl)->assertElementNotContains('.actions', 'New Page');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'create']);
 
+        $this->get($chapter->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test content',
+        ]);
+        $resp->assertRedirect($chapter->book->getUrl('/page/test-page'));
 
-        $this->visit($chapterUrl . '/create-page')
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($chapter->book->getUrl() . '/page/test-page');
-
-        $this->visit($chapterUrl)->seeInElement('.actions', 'New Page');
+        $this->get($chapterUrl)->assertElementContains('.actions', 'New Page');
     }
 
     public function test_chapter_update_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $chapterPage = $chapter->pages->first();
 
         $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl . '/edit')
-            ->see('Edit Chapter');
+        $this->actingAs($this->user)->get($chapterUrl . '/edit')
+            ->assertSee('Edit Chapter');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'delete']);
 
-        $this->forceVisit($chapterUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($chapterPage->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($chapterUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($chapterPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
 
-        $this->visit($chapterUrl . '/edit')
-            ->seePageIs($chapterUrl . '/edit')->see('Edit Chapter');
-        $this->visit($chapterPage->getUrl() . '/edit')
-            ->seePageIs($chapterPage->getUrl() . '/edit');
+        $this->get($chapterUrl . '/edit')->assertOk()->assertSee('Edit Chapter');
+        $this->get($chapterPage->getUrl() . '/edit')->assertOk();
     }
 
     public function test_chapter_delete_restriction()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $chapterPage = $chapter->pages->first();
 
         $chapterUrl = $chapter->getUrl();
         $this->actingAs($this->user)
-            ->visit($chapterUrl . '/delete')
-            ->see('Delete Chapter');
+            ->get($chapterUrl . '/delete')
+            ->assertSee('Delete Chapter');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
 
-        $this->forceVisit($chapterUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($chapterPage->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($chapterUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($chapterPage->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($chapter, ['view', 'delete']);
 
-        $this->visit($chapterUrl . '/delete')
-            ->seePageIs($chapterUrl . '/delete')->see('Delete Chapter');
-        $this->visit($chapterPage->getUrl() . '/delete')
-            ->seePageIs($chapterPage->getUrl() . '/delete')->see('Delete Page');
+        $this->get($chapterUrl . '/delete')->assertOk()->assertSee('Delete Chapter');
+        $this->get($chapterPage->getUrl() . '/delete')->assertOk()->assertSee('Delete Page');
     }
 
     public function test_page_view_restriction()
     {
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
         $pageUrl = $page->getUrl();
-        $this->actingAs($this->user)
-            ->visit($pageUrl)
-            ->seePageIs($pageUrl);
+        $this->actingAs($this->user)->get($pageUrl)->assertOk();
 
         $this->setRestrictionsForTestRoles($page, ['update', 'delete']);
 
-        $this->forceVisit($pageUrl)
-            ->see('Page not found');
+        $this->get($pageUrl)->assertSee('Page not found');
 
         $this->setRestrictionsForTestRoles($page, ['view']);
 
-        $this->visit($pageUrl)
-            ->see($page->name);
+        $this->get($pageUrl)->assertSee($page->name);
     }
 
     public function test_page_update_restriction()
     {
-        $page = Chapter::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
         $pageUrl = $page->getUrl();
         $this->actingAs($this->user)
-            ->visit($pageUrl . '/edit')
-            ->seeInField('name', $page->name);
+            ->get($pageUrl . '/edit')
+            ->assertElementExists('input[name="name"][value="' . $page->name . '"]');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
 
-        $this->forceVisit($pageUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($pageUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'update']);
 
-        $this->visit($pageUrl . '/edit')
-            ->seePageIs($pageUrl . '/edit')->seeInField('name', $page->name);
+        $this->get($pageUrl . '/edit')
+            ->assertOk()
+            ->assertElementExists('input[name="name"][value="' . $page->name . '"]');
     }
 
     public function test_page_delete_restriction()
     {
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
 
         $pageUrl = $page->getUrl();
         $this->actingAs($this->user)
-            ->visit($pageUrl . '/delete')
-            ->see('Delete Page');
+            ->get($pageUrl . '/delete')
+            ->assertSee('Delete Page');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'update']);
 
-        $this->forceVisit($pageUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($pageUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
 
-        $this->visit($pageUrl . '/delete')
-            ->seePageIs($pageUrl . '/delete')->see('Delete Page');
+        $this->get($pageUrl . '/delete')->assertOk()->assertSee('Delete Page');
+    }
+
+    protected function entityRestrictionFormTest(string $model, string $title, string $permission, string $roleId)
+    {
+        /** @var Entity $modelInstance */
+        $modelInstance = $model::query()->first();
+        $this->asAdmin()->get($modelInstance->getUrl('/permissions'))
+            ->assertSee($title);
+
+        $this->put($modelInstance->getUrl('/permissions'), [
+            'restricted'   => 'true',
+            'restrictions' => [
+                $roleId => [
+                    $permission => 'true',
+                ],
+            ],
+        ]);
+
+        $this->assertDatabaseHas($modelInstance->getTable(), ['id' => $modelInstance->id, 'restricted' => true]);
+        $this->assertDatabaseHas('entity_permissions', [
+            'restrictable_id'   => $modelInstance->id,
+            'restrictable_type' => $modelInstance->getMorphClass(),
+            'role_id'           => $roleId,
+            'action'            => $permission,
+        ]);
     }
 
     public function test_bookshelf_restriction_form()
     {
-        $shelf = Bookshelf::first();
-        $this->asAdmin()->visit($shelf->getUrl('/permissions'))
-            ->see('Bookshelf Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][view]')
-            ->press('Save Permissions')
-            ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id' => $shelf->id,
-                'restrictable_type' => Bookshelf::newModelInstance()->getMorphClass(),
-                'role_id' => '2',
-                'action' => 'view'
-            ]);
+        $this->entityRestrictionFormTest(Bookshelf::class, 'Bookshelf Permissions', 'view', '2');
     }
 
     public function test_book_restriction_form()
     {
-        $book = Book::first();
-        $this->asAdmin()->visit($book->getUrl() . '/permissions')
-            ->see('Book Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][view]')
-            ->press('Save Permissions')
-            ->seeInDatabase('books', ['id' => $book->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id' => $book->id,
-                'restrictable_type' => Book::newModelInstance()->getMorphClass(),
-                'role_id' => '2',
-                'action' => 'view'
-            ]);
+        $this->entityRestrictionFormTest(Book::class, 'Book Permissions', 'view', '2');
     }
 
     public function test_chapter_restriction_form()
     {
-        $chapter = Chapter::first();
-        $this->asAdmin()->visit($chapter->getUrl() . '/permissions')
-            ->see('Chapter Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][update]')
-            ->press('Save Permissions')
-            ->seeInDatabase('chapters', ['id' => $chapter->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id' => $chapter->id,
-                'restrictable_type' => Chapter::newModelInstance()->getMorphClass(),
-                'role_id' => '2',
-                'action' => 'update'
-            ]);
+        $this->entityRestrictionFormTest(Chapter::class, 'Chapter Permissions', 'update', '2');
     }
 
     public function test_page_restriction_form()
     {
-        $page = Page::first();
-        $this->asAdmin()->visit($page->getUrl() . '/permissions')
-            ->see('Page Permissions')
-            ->check('restricted')
-            ->check('restrictions[2][delete]')
-            ->press('Save Permissions')
-            ->seeInDatabase('pages', ['id' => $page->id, 'restricted' => true])
-            ->seeInDatabase('entity_permissions', [
-                'restrictable_id' => $page->id,
-                'restrictable_type' => Page::newModelInstance()->getMorphClass(),
-                'role_id' => '2',
-                'action' => 'delete'
-            ]);
+        $this->entityRestrictionFormTest(Page::class, 'Page Permissions', 'delete', '2');
     }
 
     public function test_restricted_pages_not_visible_in_book_navigation_on_pages()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages->first();
         $page2 = $chapter->pages[2];
 
         $this->setRestrictionsForTestRoles($page, []);
 
         $this->actingAs($this->user)
-            ->visit($page2->getUrl())
-            ->dontSeeInElement('.sidebar-page-list', $page->name);
+            ->get($page2->getUrl())
+            ->assertElementNotContains('.sidebar-page-list', $page->name);
     }
 
     public function test_restricted_pages_not_visible_in_book_navigation_on_chapters()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages->first();
 
         $this->setRestrictionsForTestRoles($page, []);
 
         $this->actingAs($this->user)
-            ->visit($chapter->getUrl())
-            ->dontSeeInElement('.sidebar-page-list', $page->name);
+            ->get($chapter->getUrl())
+            ->assertElementNotContains('.sidebar-page-list', $page->name);
     }
 
     public function test_restricted_pages_not_visible_on_chapter_pages()
     {
-        $chapter = Chapter::first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $page = $chapter->pages->first();
 
         $this->setRestrictionsForTestRoles($page, []);
 
         $this->actingAs($this->user)
-            ->visit($chapter->getUrl())
-            ->dontSee($page->name);
+            ->get($chapter->getUrl())
+            ->assertDontSee($page->name);
     }
 
     public function test_restricted_chapter_pages_not_visible_on_book_page()
     {
+        /** @var Chapter $chapter */
         $chapter = Chapter::query()->first();
         $this->actingAs($this->user)
-            ->visit($chapter->book->getUrl())
-            ->see($chapter->pages->first()->name);
+            ->get($chapter->book->getUrl())
+            ->assertSee($chapter->pages->first()->name);
 
         foreach ($chapter->pages as $page) {
             $this->setRestrictionsForTestRoles($page, []);
         }
 
         $this->actingAs($this->user)
-            ->visit($chapter->book->getUrl())
-            ->dontSee($chapter->pages->first()->name);
+            ->get($chapter->book->getUrl())
+            ->assertDontSee($chapter->pages->first()->name);
     }
 
     public function test_bookshelf_update_restriction_override()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->viewer)
-            ->visit($shelf->getUrl('/edit'))
-            ->dontSee('Edit Book');
+            ->get($shelf->getUrl('/edit'))
+            ->assertDontSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->forceVisit($shelf->getUrl('/edit'))
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($shelf->getUrl('/edit'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->visit($shelf->getUrl('/edit'))
-            ->seePageIs($shelf->getUrl('/edit'));
+        $this->get($shelf->getUrl('/edit'))->assertOk();
     }
 
     public function test_bookshelf_delete_restriction_override()
     {
-        $shelf = Bookshelf::first();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
 
         $this->actingAs($this->viewer)
-            ->visit($shelf->getUrl('/delete'))
-            ->dontSee('Delete Book');
+            ->get($shelf->getUrl('/delete'))
+            ->assertDontSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
 
-        $this->forceVisit($shelf->getUrl('/delete'))
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($shelf->getUrl('/delete'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
 
-        $this->visit($shelf->getUrl('/delete'))
-            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+        $this->get($shelf->getUrl('/delete'))->assertOk()->assertSee('Delete Book');
     }
 
     public function test_book_create_restriction_override()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->viewer)
-            ->visit($bookUrl)
-            ->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+            ->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete', 'update']);
 
-        $this->forceVisit($bookUrl . '/create-chapter')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($bookUrl)->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
+        $this->get($bookUrl . '/create-chapter')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookUrl . '/create-page')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookUrl)->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'create']);
 
-        $this->visit($bookUrl . '/create-chapter')
-            ->type('test chapter', 'name')
-            ->type('test description for chapter', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($bookUrl . '/chapter/test-chapter');
-        $this->visit($bookUrl . '/create-page')
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($bookUrl . '/page/test-page');
-        $this->visit($bookUrl)->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
+        $resp = $this->post($book->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'test desc',
+        ]);
+        $resp->assertRedirect($book->getUrl('/chapter/test-chapter'));
+
+        $this->get($book->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test desc',
+        ]);
+        $resp->assertRedirect($book->getUrl('/page/test-page'));
+
+        $this->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
     }
 
     public function test_book_update_restriction_override()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
-        $this->actingAs($this->viewer)
-            ->visit($bookUrl . '/edit')
-            ->dontSee('Edit Book');
+        $this->actingAs($this->viewer)->get($bookUrl . '/edit')
+            ->assertDontSee('Edit Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->forceVisit($bookUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->visit($bookUrl . '/edit')
-            ->seePageIs($bookUrl . '/edit');
-        $this->visit($bookPage->getUrl() . '/edit')
-            ->seePageIs($bookPage->getUrl() . '/edit');
-        $this->visit($bookChapter->getUrl() . '/edit')
-            ->see('Edit Chapter');
+        $this->get($bookUrl . '/edit')->assertOk();
+        $this->get($bookPage->getUrl() . '/edit')->assertOk();
+        $this->get($bookChapter->getUrl() . '/edit')->assertSee('Edit Chapter');
     }
 
     public function test_book_delete_restriction_override()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookPage = $book->pages->first();
         $bookChapter = $book->chapters->first();
 
         $bookUrl = $book->getUrl();
         $this->actingAs($this->viewer)
-            ->visit($bookUrl . '/delete')
-            ->dontSee('Delete Book');
+            ->get($bookUrl . '/delete')
+            ->assertDontSee('Delete Book');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'update']);
 
-        $this->forceVisit($bookUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookPage->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->forceVisit($bookChapter->getUrl() . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
+        $this->get($bookUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookPage->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+        $this->get($bookChapter->getUrl() . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
 
-        $this->visit($bookUrl . '/delete')
-            ->seePageIs($bookUrl . '/delete')->see('Delete Book');
-        $this->visit($bookPage->getUrl() . '/delete')
-            ->seePageIs($bookPage->getUrl() . '/delete')->see('Delete Page');
-        $this->visit($bookChapter->getUrl() . '/delete')
-            ->see('Delete Chapter');
+        $this->get($bookUrl . '/delete')->assertOk()->assertSee('Delete Book');
+        $this->get($bookPage->getUrl() . '/delete')->assertOk()->assertSee('Delete Page');
+        $this->get($bookChapter->getUrl() . '/delete')->assertSee('Delete Chapter');
     }
 
     public function test_page_visible_if_has_permissions_when_book_not_visible()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $bookChapter = $book->chapters->first();
         $bookPage = $bookChapter->pages->first();
 
@@ -655,93 +645,51 @@ class EntityPermissionsTest extends BrowserKitTest
         $this->setRestrictionsForTestRoles($bookPage, ['view']);
 
         $this->actingAs($this->viewer);
-        $this->get($bookPage->getUrl());
-        $this->assertResponseOk();
-        $this->see($bookPage->name);
-        $this->dontSee(substr($book->name, 0, 15));
-        $this->dontSee(substr($bookChapter->name, 0, 15));
+        $resp = $this->get($bookPage->getUrl());
+        $resp->assertOk();
+        $resp->assertSee($bookPage->name);
+        $resp->assertDontSee(substr($book->name, 0, 15));
+        $resp->assertDontSee(substr($bookChapter->name, 0, 15));
     }
 
     public function test_book_sort_view_permission()
     {
-        $firstBook = Book::first();
-        $secondBook = Book::find(2);
+        /** @var Book $firstBook */
+        $firstBook = Book::query()->first();
+        /** @var Book $secondBook */
+        $secondBook = Book::query()->find(2);
 
         $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
         $this->setRestrictionsForTestRoles($secondBook, ['view']);
 
         // Test sort page visibility
-        $this->actingAs($this->user)->visit($secondBook->getUrl() . '/sort')
-                ->see('You do not have permission')
-                ->seePageIs('/');
+        $this->actingAs($this->user)->get($secondBook->getUrl('/sort'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
 
         // Check sort page on first book
-        $this->actingAs($this->user)->visit($firstBook->getUrl() . '/sort');
-    }
-
-    public function test_book_sort_permission() {
-        $firstBook = Book::first();
-        $secondBook = Book::find(2);
-
-        $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']);
-        $this->setRestrictionsForTestRoles($secondBook, ['view']);
-
-        $firstBookChapter = $this->newChapter(['name' => 'first book chapter'], $firstBook);
-        $secondBookChapter = $this->newChapter(['name' => 'second book chapter'], $secondBook);
-
-        // Create request data
-        $reqData = [
-            [
-                'id' => $firstBookChapter->id,
-                'sort' => 0,
-                'parentChapter' => false,
-                'type' => 'chapter',
-                'book' => $secondBook->id
-            ]
-        ];
-
-        // Move chapter from first book to a second book
-        $this->actingAs($this->user)->put($firstBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)])
-                ->followRedirects()
-                ->see('You do not have permission')
-                ->seePageIs('/');
-
-        $reqData = [
-            [
-                'id' => $secondBookChapter->id,
-                'sort' => 0,
-                'parentChapter' => false,
-                'type' => 'chapter',
-                'book' => $firstBook->id
-            ]
-        ];
-
-        // Move chapter from second book to first book
-        $this->actingAs($this->user)->put($firstBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)])
-                ->followRedirects()
-                ->see('You do not have permission')
-                ->seePageIs('/');
+        $this->actingAs($this->user)->get($firstBook->getUrl('/sort'));
     }
 
     public function test_can_create_page_if_chapter_has_permissions_when_book_not_visible()
     {
-        $book = Book::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $this->setRestrictionsForTestRoles($book, []);
         $bookChapter = $book->chapters->first();
         $this->setRestrictionsForTestRoles($bookChapter, ['view']);
 
-        $this->actingAs($this->user)->visit($bookChapter->getUrl())
-            ->dontSee('New Page');
+        $this->actingAs($this->user)->get($bookChapter->getUrl())
+            ->assertDontSee('New Page');
 
         $this->setRestrictionsForTestRoles($bookChapter, ['view', 'create']);
 
-        $this->actingAs($this->user)->visit($bookChapter->getUrl())
-            ->click('New Page')
-            ->seeStatusCode(200)
-            ->type('test page', 'name')
-            ->type('test content', 'html')
-            ->press('Save Page')
-            ->seePageIs($book->getUrl('/page/test-page'))
-            ->seeStatusCode(200);
+        $this->get($bookChapter->getUrl('/create-page'));
+        /** @var Page $page */
+        $page = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $resp = $this->post($page->getUrl(), [
+            'name' => 'test page',
+            'html' => 'test content',
+        ]);
+        $resp->assertRedirect($book->getUrl('/page/test-page'));
     }
 }
index e5a1146a59b8803fdb0073cd566863b617505a89..2e3d84fa13e23a263870252f7201f2ed9818a827 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Permissions;
+<?php
+
+namespace Tests\Permissions;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
@@ -7,7 +9,6 @@ use Tests\TestCase;
 
 class ExportPermissionsTest extends TestCase
 {
-
     public function test_page_content_without_view_access_hidden_on_chapter_export()
     {
         $chapter = Chapter::query()->first();
@@ -63,5 +64,4 @@ class ExportPermissionsTest extends TestCase
             $resp->assertDontSee($pageContent);
         }
     }
-
-}
\ No newline at end of file
+}
index 8398d08281a12d4e0239ae1531f4817d86b0c1a5..f69b5603c31ce30bf5523c3a705babfb8d2eaa3b 100644 (file)
@@ -1,21 +1,25 @@
-<?php namespace Tests\Permissions;
+<?php
 
+namespace Tests\Permissions;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\Comment;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
-use BookStack\Auth\Role;
 use BookStack\Uploads\Image;
-use Laravel\BrowserKitTesting\HttpException;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
+use Tests\TestResponse;
 
-class RolesTest extends BrowserKitTest
+class RolesTest extends TestCase
 {
     protected $user;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
         $this->user = $this->getViewer();
@@ -23,17 +27,17 @@ class RolesTest extends BrowserKitTest
 
     public function test_admin_can_see_settings()
     {
-        $this->asAdmin()->visit('/settings')->see('Settings');
+        $this->asAdmin()->get('/settings')->assertSee('Settings');
     }
 
     public function test_cannot_delete_admin_role()
     {
         $adminRole = Role::getRole('admin');
         $deletePageUrl = '/settings/roles/delete/' . $adminRole->id;
-        $this->asAdmin()->visit($deletePageUrl)
-            ->press('Confirm')
-            ->seePageIs($deletePageUrl)
-            ->see('cannot be deleted');
+
+        $this->asAdmin()->get($deletePageUrl);
+        $this->delete($deletePageUrl)->assertRedirect($deletePageUrl);
+        $this->get($deletePageUrl)->assertSee('cannot be deleted');
     }
 
     public function test_role_cannot_be_deleted_if_default()
@@ -42,10 +46,9 @@ class RolesTest extends BrowserKitTest
         $this->setSettings(['registration-role' => $newRole->id]);
 
         $deletePageUrl = '/settings/roles/delete/' . $newRole->id;
-        $this->asAdmin()->visit($deletePageUrl)
-            ->press('Confirm')
-            ->seePageIs($deletePageUrl)
-            ->see('cannot be deleted');
+        $this->asAdmin()->get($deletePageUrl);
+        $this->delete($deletePageUrl)->assertRedirect($deletePageUrl);
+        $this->get($deletePageUrl)->assertSee('cannot be deleted');
     }
 
     public function test_role_create_update_delete_flow()
@@ -55,67 +58,104 @@ class RolesTest extends BrowserKitTest
         $testRoleUpdateName = 'An Super Updated role';
 
         // Creation
-        $this->asAdmin()->visit('/settings')
-            ->click('Roles')
-            ->seePageIs('/settings/roles')
-            ->click('Create New Role')
-            ->type('Test Role', 'display_name')
-            ->type('A little test description', 'description')
-            ->press('Save Role')
-            ->seeInDatabase('roles', ['display_name' => $testRoleName, 'description' => $testRoleDesc])
-            ->seePageIs('/settings/roles');
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertElementContains('a[href="' . url('/settings/roles') . '"]', 'Roles');
+
+        $resp = $this->get('/settings/roles');
+        $resp->assertElementContains('a[href="' . url('/settings/roles/new') . '"]', 'Create New Role');
+
+        $resp = $this->get('/settings/roles/new');
+        $resp->assertElementContains('form[action="' . url('/settings/roles/new') . '"]', 'Save Role');
+
+        $resp = $this->post('/settings/roles/new', [
+            'display_name' => $testRoleName,
+            'description'  => $testRoleDesc,
+        ]);
+        $resp->assertRedirect('/settings/roles');
+
+        $resp = $this->get('/settings/roles');
+        $resp->assertSee($testRoleName);
+        $resp->assertSee($testRoleDesc);
+        $this->assertDatabaseHas('roles', [
+            'display_name' => $testRoleName,
+            'description'  => $testRoleDesc,
+            'mfa_enforced' => false,
+        ]);
+
+        /** @var Role $role */
+        $role = Role::query()->where('display_name', '=', $testRoleName)->first();
+
         // Updating
-        $this->asAdmin()->visit('/settings/roles')
-            ->see($testRoleDesc)
-            ->click($testRoleName)
-            ->type($testRoleUpdateName, '#display_name')
-            ->press('Save Role')
-            ->seeInDatabase('roles', ['display_name' => $testRoleUpdateName, 'description' => $testRoleDesc])
-            ->seePageIs('/settings/roles');
+        $resp = $this->get('/settings/roles/' . $role->id);
+        $resp->assertSee($testRoleName);
+        $resp->assertSee($testRoleDesc);
+        $resp->assertElementContains('form[action="' . url('/settings/roles/' . $role->id) . '"]', 'Save Role');
+
+        $resp = $this->put('/settings/roles/' . $role->id, [
+            'display_name' => $testRoleUpdateName,
+            'description'  => $testRoleDesc,
+            'mfa_enforced' => 'true',
+        ]);
+        $resp->assertRedirect('/settings/roles');
+        $this->assertDatabaseHas('roles', [
+            'display_name' => $testRoleUpdateName,
+            'description'  => $testRoleDesc,
+            'mfa_enforced' => true,
+        ]);
+
         // Deleting
-        $this->asAdmin()->visit('/settings/roles')
-            ->click($testRoleUpdateName)
-            ->click('Delete Role')
-            ->see($testRoleUpdateName)
-            ->press('Confirm')
-            ->seePageIs('/settings/roles')
-            ->dontSee($testRoleUpdateName);
+        $resp = $this->get('/settings/roles/' . $role->id);
+        $resp->assertElementContains('a[href="' . url("/settings/roles/delete/$role->id") . '"]', 'Delete Role');
+
+        $resp = $this->get("/settings/roles/delete/$role->id");
+        $resp->assertSee($testRoleUpdateName);
+        $resp->assertElementContains('form[action="' . url("/settings/roles/delete/$role->id") . '"]', 'Confirm');
+
+        $resp = $this->delete("/settings/roles/delete/$role->id");
+        $resp->assertRedirect('/settings/roles');
+        $this->get('/settings/roles')->assertSee('Role successfully deleted');
+        $this->assertActivityExists(ActivityType::ROLE_DELETE);
     }
 
-    public function test_admin_role_cannot_be_removed_if_last_admin()
+    public function test_admin_role_cannot_be_removed_if_user_last_admin()
     {
-        $adminRole = Role::where('system_name', '=', 'admin')->first();
+        /** @var Role $adminRole */
+        $adminRole = Role::query()->where('system_name', '=', 'admin')->first();
         $adminUser = $this->getAdmin();
         $adminRole->users()->where('id', '!=', $adminUser->id)->delete();
-        $this->assertEquals($adminRole->users()->count(), 1);
+        $this->assertEquals(1, $adminRole->users()->count());
 
         $viewerRole = $this->getViewer()->roles()->first();
 
         $editUrl = '/settings/users/' . $adminUser->id;
-        $this->actingAs($adminUser)->put($editUrl, [
-            'name' => $adminUser->name,
+        $resp = $this->actingAs($adminUser)->put($editUrl, [
+            'name'  => $adminUser->name,
             'email' => $adminUser->email,
             'roles' => [
                 'viewer' => strval($viewerRole->id),
-            ]
-        ])->followRedirects();
+            ],
+        ]);
+
+        $resp->assertRedirect($editUrl);
 
-        $this->seePageIs($editUrl);
-        $this->see('This user is the only user assigned to the administrator role');
+        $resp = $this->get($editUrl);
+        $resp->assertSee('This user is the only user assigned to the administrator role');
     }
 
     public function test_migrate_users_on_delete_works()
     {
+        /** @var Role $roleA */
         $roleA = Role::query()->create(['display_name' => 'Delete Test A']);
+        /** @var Role $roleB */
         $roleB = Role::query()->create(['display_name' => 'Delete Test B']);
         $this->user->attachRole($roleB);
 
         $this->assertCount(0, $roleA->users()->get());
         $this->assertCount(1, $roleB->users()->get());
 
-        $deletePage = $this->asAdmin()->get("/settings/roles/delete/{$roleB->id}");
-        $deletePage->seeElement('select[name=migrate_role_id]');
-        $this->asAdmin()->delete("/settings/roles/delete/{$roleB->id}", [
+        $deletePage = $this->asAdmin()->get("/settings/roles/delete/$roleB->id");
+        $deletePage->assertElementExists('select[name=migrate_role_id]');
+        $this->asAdmin()->delete("/settings/roles/delete/$roleB->id", [
             'migrate_role_id' => $roleA->id,
         ]);
 
@@ -123,23 +163,38 @@ class RolesTest extends BrowserKitTest
         $this->assertEquals($this->user->id, $roleA->users()->first()->id);
     }
 
+    public function test_copy_role_button_shown()
+    {
+        /** @var Role $role */
+        $role = Role::query()->first();
+        $resp = $this->asAdmin()->get("/settings/roles/{$role->id}");
+        $resp->assertElementContains('a[href$="/roles/new?copy_from=' . $role->id . '"]', 'Copy');
+    }
+
+    public function test_copy_from_param_on_create_prefills_with_other_role_data()
+    {
+        /** @var Role $role */
+        $role = Role::query()->first();
+        $resp = $this->asAdmin()->get("/settings/roles/new?copy_from={$role->id}");
+        $resp->assertOk();
+        $resp->assertElementExists('input[name="display_name"][value="' . ($role->display_name . ' (Copy)') . '"]');
+    }
+
     public function test_manage_user_permission()
     {
-        $this->actingAs($this->user)->visit('/settings/users')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get('/settings/users')->assertRedirect('/');
         $this->giveUserPermissions($this->user, ['users-manage']);
-        $this->actingAs($this->user)->visit('/settings/users')
-            ->seePageIs('/settings/users');
+        $this->actingAs($this->user)->get('/settings/users')->assertOk();
     }
 
     public function test_manage_users_permission_shows_link_in_header_if_does_not_have_settings_manage_permision()
     {
-        $usersLink = 'href="'.url('/settings/users') . '"';
-        $this->actingAs($this->user)->visit('/')->dontSee($usersLink);
+        $usersLink = 'href="' . url('/settings/users') . '"';
+        $this->actingAs($this->user)->get('/')->assertDontSee($usersLink, false);
         $this->giveUserPermissions($this->user, ['users-manage']);
-        $this->actingAs($this->user)->visit('/')->see($usersLink);
+        $this->actingAs($this->user)->get('/')->assertSee($usersLink, false);
         $this->giveUserPermissions($this->user, ['settings-manage', 'users-manage']);
-        $this->actingAs($this->user)->visit('/')->dontSee($usersLink);
+        $this->actingAs($this->user)->get('/')->assertDontSee($usersLink, false);
     }
 
     public function test_user_cannot_change_email_unless_they_have_manage_users_permission()
@@ -148,73 +203,80 @@ class RolesTest extends BrowserKitTest
         $originalEmail = $this->user->email;
         $this->actingAs($this->user);
 
-        $this->visit($userProfileUrl)
-            ->assertResponseOk()
-            ->seeElement('input[name=email][disabled]');
+        $this->get($userProfileUrl)
+            ->assertOk()
+            ->assertElementExists('input[name=email][disabled]');
         $this->put($userProfileUrl, [
-            'name' => 'my_new_name',
+            'name'  => 'my_new_name',
             'email' => '[email protected]',
         ]);
-        $this->seeInDatabase('users', [
-            'id' => $this->user->id,
+        $this->assertDatabaseHas('users', [
+            'id'    => $this->user->id,
             'email' => $originalEmail,
-            'name' => 'my_new_name',
+            'name'  => 'my_new_name',
         ]);
 
         $this->giveUserPermissions($this->user, ['users-manage']);
 
-        $this->visit($userProfileUrl)
-            ->assertResponseOk()
-            ->dontSeeElement('input[name=email][disabled]')
-            ->seeElement('input[name=email]');
+        $this->get($userProfileUrl)
+            ->assertOk()
+            ->assertElementNotExists('input[name=email][disabled]')
+            ->assertElementExists('input[name=email]');
         $this->put($userProfileUrl, [
-            'name' => 'my_new_name_2',
+            'name'  => 'my_new_name_2',
             'email' => '[email protected]',
         ]);
 
-        $this->seeInDatabase('users', [
-            'id' => $this->user->id,
+        $this->assertDatabaseHas('users', [
+            'id'    => $this->user->id,
             'email' => '[email protected]',
-            'name' => 'my_new_name_2',
+            'name'  => 'my_new_name_2',
         ]);
     }
 
     public function test_user_roles_manage_permission()
     {
-        $this->actingAs($this->user)->visit('/settings/roles')
-            ->seePageIs('/')->visit('/settings/roles/1')->seePageIs('/');
+        $this->actingAs($this->user)->get('/settings/roles')->assertRedirect('/');
+        $this->get('/settings/roles/1')->assertRedirect('/');
         $this->giveUserPermissions($this->user, ['user-roles-manage']);
-        $this->actingAs($this->user)->visit('/settings/roles')
-            ->seePageIs('/settings/roles')->click('Admin')
-            ->see('Edit Role');
+        $this->actingAs($this->user)->get('/settings/roles')->assertOk();
+        $this->get('/settings/roles/1')
+            ->assertOk()
+            ->assertSee('Admin');
     }
 
     public function test_settings_manage_permission()
     {
-        $this->actingAs($this->user)->visit('/settings')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get('/settings')->assertRedirect('/');
         $this->giveUserPermissions($this->user, ['settings-manage']);
-        $this->actingAs($this->user)->visit('/settings')
-            ->seePageIs('/settings')->press('Save Settings')->see('Settings Saved');
+        $this->get('/settings')->assertOk();
+
+        $resp = $this->post('/settings', []);
+        $resp->assertRedirect('/settings');
+        $resp = $this->get('/settings');
+        $resp->assertSee('Settings saved');
     }
 
     public function test_restrictions_manage_all_permission()
     {
-        $page = Page::take(1)->get()->first();
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->dontSee('Permissions')
-            ->visit($page->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $page = Page::query()->get()->first();
+
+        $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions');
+        $this->get($page->getUrl('/permissions'))->assertRedirect('/');
+
         $this->giveUserPermissions($this->user, ['restrictions-manage-all']);
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->see('Permissions')
-            ->click('Permissions')
-            ->see('Page Permissions')->seePageIs($page->getUrl() . '/permissions');
+
+        $this->actingAs($this->user)->get($page->getUrl())->assertSee('Permissions');
+
+        $this->get($page->getUrl('/permissions'))
+            ->assertOk()
+            ->assertSee('Page Permissions');
     }
 
     public function test_restrictions_manage_own_permission()
     {
-        $otherUsersPage = Page::first();
+        /** @var Page $otherUsersPage */
+        $otherUsersPage = Page::query()->first();
         $content = $this->createEntityChainBelongingToUser($this->user);
 
         // Set a different creator on the page we're checking to ensure
@@ -225,328 +287,309 @@ class RolesTest extends BrowserKitTest
         $page->save();
 
         // Check can't restrict other's content
-        $this->actingAs($this->user)->visit($otherUsersPage->getUrl())
-            ->dontSee('Permissions')
-            ->visit($otherUsersPage->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get($otherUsersPage->getUrl())->assertDontSee('Permissions');
+        $this->get($otherUsersPage->getUrl('/permissions'))->assertRedirect('/');
+
         // Check can't restrict own content
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->dontSee('Permissions')
-            ->visit($page->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions');
+        $this->get($page->getUrl('/permissions'))->assertRedirect('/');
 
         $this->giveUserPermissions($this->user, ['restrictions-manage-own']);
 
         // Check can't restrict other's content
-        $this->actingAs($this->user)->visit($otherUsersPage->getUrl())
-            ->dontSee('Permissions')
-            ->visit($otherUsersPage->getUrl() . '/permissions')
-            ->seePageIs('/');
+        $this->actingAs($this->user)->get($otherUsersPage->getUrl())->assertDontSee('Permissions');
+        $this->get($otherUsersPage->getUrl('/permissions'))->assertRedirect();
+
         // Check can restrict own content
-        $this->actingAs($this->user)->visit($page->getUrl())
-            ->see('Permissions')
-            ->click('Permissions')
-            ->seePageIs($page->getUrl() . '/permissions');
+        $this->actingAs($this->user)->get($page->getUrl())->assertSee('Permissions');
+        $this->get($page->getUrl('/permissions'))->assertOk();
     }
 
     /**
-     * Check a standard entity access permission
-     * @param string $permission
-     * @param array $accessUrls Urls that are only accessible after having the permission
-     * @param array $visibles Check this text, In the buttons toolbar, is only visible with the permission
+     * Check a standard entity access permission.
      */
-    private function checkAccessPermission($permission, $accessUrls = [], $visibles = [])
+    private function checkAccessPermission(string $permission, array $accessUrls = [], array $visibles = [])
     {
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect('/');
         }
+
         foreach ($visibles as $url => $text) {
-            $this->actingAs($this->user)->visit($url)
-                ->dontSeeInElement('.action-buttons',$text);
+            $this->actingAs($this->user)->get($url)
+                ->assertElementNotContains('.action-buttons', $text);
         }
 
         $this->giveUserPermissions($this->user, [$permission]);
 
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs($url);
+            $this->actingAs($this->user)->get($url)->assertOk();
         }
         foreach ($visibles as $url => $text) {
-            $this->actingAs($this->user)->visit($url)
-                ->see($text);
+            $this->actingAs($this->user)->get($url)->assertSee($text);
         }
     }
 
     public function test_bookshelves_create_all_permissions()
     {
         $this->checkAccessPermission('bookshelf-create-all', [
-            '/create-shelf'
+            '/create-shelf',
         ], [
-            '/shelves' => 'New Shelf'
+            '/shelves' => 'New Shelf',
         ]);
 
-        $this->visit('/create-shelf')
-            ->type('test shelf', 'name')
-            ->type('shelf desc', 'description')
-            ->press('Save Shelf')
-            ->seePageIs('/shelves/test-shelf');
+        $this->post('/shelves', [
+            'name'        => 'test shelf',
+            'description' => 'shelf desc',
+        ])->assertRedirect('/shelves/test-shelf');
     }
 
     public function test_bookshelves_edit_own_permission()
     {
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
         $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
         $this->regenEntityPermissions($ownShelf);
 
         $this->checkAccessPermission('bookshelf-update-own', [
-            $ownShelf->getUrl('/edit')
+            $ownShelf->getUrl('/edit'),
         ], [
-            $ownShelf->getUrl() => 'Edit'
+            $ownShelf->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherShelf->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherShelf->getUrl('/edit'))
-            ->seePageIs('/');
+        $this->get($otherShelf->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherShelf->getUrl('/edit'))->assertRedirect('/');
     }
 
     public function test_bookshelves_edit_all_permission()
     {
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $this->checkAccessPermission('bookshelf-update-all', [
-            $otherShelf->getUrl('/edit')
+            $otherShelf->getUrl('/edit'),
         ], [
-            $otherShelf->getUrl() => 'Edit'
+            $otherShelf->getUrl() => 'Edit',
         ]);
     }
 
     public function test_bookshelves_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
         $ownShelf->forceFill(['owned_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
         $this->regenEntityPermissions($ownShelf);
 
         $this->checkAccessPermission('bookshelf-delete-own', [
-            $ownShelf->getUrl('/delete')
+            $ownShelf->getUrl('/delete'),
         ], [
-            $ownShelf->getUrl() => 'Delete'
+            $ownShelf->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherShelf->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherShelf->getUrl('/delete'))
-            ->seePageIs('/');
-        $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete'))
-            ->press('Confirm')
-            ->seePageIs('/shelves')
-            ->dontSee($ownShelf->name);
+        $this->get($otherShelf->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherShelf->getUrl('/delete'))->assertRedirect('/');
+
+        $this->get($ownShelf->getUrl());
+        $this->delete($ownShelf->getUrl())->assertRedirect('/shelves');
+        $this->get('/shelves')->assertDontSee($ownShelf->name);
     }
 
     public function test_bookshelves_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
-        $otherShelf = Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $this->checkAccessPermission('bookshelf-delete-all', [
-            $otherShelf->getUrl('/delete')
+            $otherShelf->getUrl('/delete'),
         ], [
-            $otherShelf->getUrl() => 'Delete'
+            $otherShelf->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete'))
-            ->press('Confirm')
-            ->seePageIs('/shelves')
-            ->dontSee($otherShelf->name);
+        $this->delete($otherShelf->getUrl())->assertRedirect('/shelves');
+        $this->get('/shelves')->assertDontSee($otherShelf->name);
     }
 
     public function test_books_create_all_permissions()
     {
         $this->checkAccessPermission('book-create-all', [
-            '/create-book'
+            '/create-book',
         ], [
-            '/books' => 'Create New Book'
+            '/books' => 'Create New Book',
         ]);
 
-        $this->visit('/create-book')
-            ->type('test book', 'name')
-            ->type('book desc', 'description')
-            ->press('Save Book')
-            ->seePageIs('/books/test-book');
+        $this->post('/books', [
+            'name'        => 'test book',
+            'description' => 'book desc',
+        ])->assertRedirect('/books/test-book');
     }
 
     public function test_books_edit_own_permission()
     {
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('book-update-own', [
-            $ownBook->getUrl() . '/edit'
+            $ownBook->getUrl() . '/edit',
         ], [
-            $ownBook->getUrl() => 'Edit'
+            $ownBook->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherBook->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherBook->getUrl() . '/edit')
-            ->seePageIs('/');
+        $this->get($otherBook->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherBook->getUrl('/edit'))->assertRedirect('/');
     }
 
     public function test_books_edit_all_permission()
     {
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $this->checkAccessPermission('book-update-all', [
-            $otherBook->getUrl() . '/edit'
+            $otherBook->getUrl() . '/edit',
         ], [
-            $otherBook->getUrl() => 'Edit'
+            $otherBook->getUrl() => 'Edit',
         ]);
     }
 
     public function test_books_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['book-update-all']);
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('book-delete-own', [
-            $ownBook->getUrl() . '/delete'
+            $ownBook->getUrl() . '/delete',
         ], [
-            $ownBook->getUrl() => 'Delete'
+            $ownBook->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherBook->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherBook->getUrl() . '/delete')
-            ->seePageIs('/');
-        $this->visit($ownBook->getUrl())->visit($ownBook->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs('/books')
-            ->dontSee($ownBook->name);
+        $this->get($otherBook->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherBook->getUrl('/delete'))->assertRedirect('/');
+        $this->get($ownBook->getUrl());
+        $this->delete($ownBook->getUrl())->assertRedirect('/books');
+        $this->get('/books')->assertDontSee($ownBook->name);
     }
 
     public function test_books_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['book-update-all']);
-        $otherBook = Book::take(1)->get()->first();
+        /** @var Book $otherBook */
+        $otherBook = Book::query()->take(1)->get()->first();
         $this->checkAccessPermission('book-delete-all', [
-            $otherBook->getUrl() . '/delete'
+            $otherBook->getUrl() . '/delete',
         ], [
-            $otherBook->getUrl() => 'Delete'
+            $otherBook->getUrl() => 'Delete',
         ]);
 
-        $this->visit($otherBook->getUrl())->visit($otherBook->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs('/books')
-            ->dontSee($otherBook->name);
+        $this->get($otherBook->getUrl());
+        $this->delete($otherBook->getUrl())->assertRedirect('/books');
+        $this->get('/books')->assertDontSee($otherBook->name);
     }
 
     public function test_chapter_create_own_permissions()
     {
-        $book = Book::take(1)->get()->first();
+        /** @var Book $book */
+        $book = Book::query()->take(1)->get()->first();
         $ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
         $this->checkAccessPermission('chapter-create-own', [
-            $ownBook->getUrl('/create-chapter')
+            $ownBook->getUrl('/create-chapter'),
         ], [
-            $ownBook->getUrl() => 'New Chapter'
+            $ownBook->getUrl() => 'New Chapter',
         ]);
 
-        $this->visit($ownBook->getUrl('/create-chapter'))
-            ->type('test chapter', 'name')
-            ->type('chapter desc', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($ownBook->getUrl('/chapter/test-chapter'));
+        $this->post($ownBook->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'chapter desc',
+        ])->assertRedirect($ownBook->getUrl('/chapter/test-chapter'));
 
-        $this->visit($book->getUrl())
-            ->dontSeeInElement('.action-buttons', 'New Chapter')
-            ->visit($book->getUrl('/create-chapter'))
-            ->seePageIs('/');
+        $this->get($book->getUrl())->assertElementNotContains('.action-buttons', 'New Chapter');
+        $this->get($book->getUrl('/create-chapter'))->assertRedirect('/');
     }
 
     public function test_chapter_create_all_permissions()
     {
-        $book = Book::take(1)->get()->first();
+        /** @var Book $book */
+        $book = Book::query()->first();
         $this->checkAccessPermission('chapter-create-all', [
-            $book->getUrl('/create-chapter')
+            $book->getUrl('/create-chapter'),
         ], [
-            $book->getUrl() => 'New Chapter'
+            $book->getUrl() => 'New Chapter',
         ]);
 
-        $this->visit($book->getUrl('/create-chapter'))
-            ->type('test chapter', 'name')
-            ->type('chapter desc', 'description')
-            ->press('Save Chapter')
-            ->seePageIs($book->getUrl('/chapter/test-chapter'));
+        $this->post($book->getUrl('/create-chapter'), [
+            'name'        => 'test chapter',
+            'description' => 'chapter desc',
+        ])->assertRedirect($book->getUrl('/chapter/test-chapter'));
     }
 
     public function test_chapter_edit_own_permission()
     {
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->first();
         $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
         $this->checkAccessPermission('chapter-update-own', [
-            $ownChapter->getUrl() . '/edit'
+            $ownChapter->getUrl() . '/edit',
         ], [
-            $ownChapter->getUrl() => 'Edit'
+            $ownChapter->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherChapter->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherChapter->getUrl() . '/edit')
-            ->seePageIs('/');
+        $this->get($otherChapter->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherChapter->getUrl('/edit'))->assertRedirect('/');
     }
 
     public function test_chapter_edit_all_permission()
     {
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->take(1)->get()->first();
         $this->checkAccessPermission('chapter-update-all', [
-            $otherChapter->getUrl() . '/edit'
+            $otherChapter->getUrl() . '/edit',
         ], [
-            $otherChapter->getUrl() => 'Edit'
+            $otherChapter->getUrl() => 'Edit',
         ]);
     }
 
     public function test_chapter_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['chapter-update-all']);
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->first();
         $ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
         $this->checkAccessPermission('chapter-delete-own', [
-            $ownChapter->getUrl() . '/delete'
+            $ownChapter->getUrl() . '/delete',
         ], [
-            $ownChapter->getUrl() => 'Delete'
+            $ownChapter->getUrl() => 'Delete',
         ]);
 
         $bookUrl = $ownChapter->book->getUrl();
-        $this->visit($otherChapter->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherChapter->getUrl() . '/delete')
-            ->seePageIs('/');
-        $this->visit($ownChapter->getUrl())->visit($ownChapter->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($bookUrl)
-            ->dontSeeInElement('.book-content', $ownChapter->name);
+        $this->get($otherChapter->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherChapter->getUrl('/delete'))->assertRedirect('/');
+        $this->get($ownChapter->getUrl());
+        $this->delete($ownChapter->getUrl())->assertRedirect($bookUrl);
+        $this->get($bookUrl)->assertElementNotContains('.book-content', $ownChapter->name);
     }
 
     public function test_chapter_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['chapter-update-all']);
-        $otherChapter = Chapter::take(1)->get()->first();
+        /** @var Chapter $otherChapter */
+        $otherChapter = Chapter::query()->first();
         $this->checkAccessPermission('chapter-delete-all', [
-            $otherChapter->getUrl() . '/delete'
+            $otherChapter->getUrl() . '/delete',
         ], [
-            $otherChapter->getUrl() => 'Delete'
+            $otherChapter->getUrl() => 'Delete',
         ]);
 
         $bookUrl = $otherChapter->book->getUrl();
-        $this->visit($otherChapter->getUrl())->visit($otherChapter->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($bookUrl)
-            ->dontSeeInElement('.book-content', $otherChapter->name);
+        $this->get($otherChapter->getUrl());
+        $this->delete($otherChapter->getUrl())->assertRedirect($bookUrl);
+        $this->get($bookUrl)->assertElementNotContains('.book-content', $otherChapter->name);
     }
 
     public function test_page_create_own_permissions()
     {
-        $book = Book::first();
-        $chapter = Chapter::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
 
         $entities = $this->createEntityChainBelongingToUser($this->user);
         $ownBook = $entities['book'];
@@ -557,357 +600,363 @@ class RolesTest extends BrowserKitTest
         $accessUrls = [$createUrl, $createUrlChapter];
 
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect('/');
         }
 
         $this->checkAccessPermission('page-create-own', [], [
-            $ownBook->getUrl() => 'New Page',
-            $ownChapter->getUrl() => 'New Page'
+            $ownBook->getUrl()    => 'New Page',
+            $ownChapter->getUrl() => 'New Page',
         ]);
 
         $this->giveUserPermissions($this->user, ['page-create-own']);
 
         foreach ($accessUrls as $index => $url) {
-            $this->actingAs($this->user)->visit($url);
-            $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
-            $this->seePageIs($expectedUrl);
+            $resp = $this->actingAs($this->user)->get($url);
+            $expectedUrl = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+            $resp->assertRedirect($expectedUrl);
         }
 
-        $this->visit($createUrl)
-            ->type('test page', 'name')
-            ->type('page desc', 'html')
-            ->press('Save Page')
-            ->seePageIs($ownBook->getUrl('/page/test-page'));
+        $this->get($createUrl);
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first();
+        $this->post($draft->getUrl(), [
+            'name' => 'test page',
+            'html' => 'page desc',
+        ])->assertRedirect($ownBook->getUrl('/page/test-page'));
+
+        $this->get($book->getUrl())->assertElementNotContains('.action-buttons', 'New Page');
+        $this->get($book->getUrl('/create-page'))->assertRedirect('/');
 
-        $this->visit($book->getUrl())
-            ->dontSeeInElement('.action-buttons', 'New Page')
-            ->visit($book->getUrl() . '/create-page')
-            ->seePageIs('/');
-        $this->visit($chapter->getUrl())
-            ->dontSeeInElement('.action-buttons', 'New Page')
-            ->visit($chapter->getUrl() . '/create-page')
-            ->seePageIs('/');
+        $this->get($chapter->getUrl())->assertElementNotContains('.action-buttons', 'New Page');
+        $this->get($chapter->getUrl('/create-page'))->assertRedirect('/');
     }
 
     public function test_page_create_all_permissions()
     {
-        $book = Book::take(1)->get()->first();
-        $chapter = Chapter::take(1)->get()->first();
-        $baseUrl = $book->getUrl() . '/page';
+        /** @var Book $book */
+        $book = Book::query()->first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
         $createUrl = $book->getUrl('/create-page');
 
         $createUrlChapter = $chapter->getUrl('/create-page');
         $accessUrls = [$createUrl, $createUrlChapter];
 
         foreach ($accessUrls as $url) {
-            $this->actingAs($this->user)->visit($url)
-                ->seePageIs('/');
+            $this->actingAs($this->user)->get($url)->assertRedirect('/');
         }
 
         $this->checkAccessPermission('page-create-all', [], [
-            $book->getUrl() => 'New Page',
-            $chapter->getUrl() => 'New Page'
+            $book->getUrl()    => 'New Page',
+            $chapter->getUrl() => 'New Page',
         ]);
 
         $this->giveUserPermissions($this->user, ['page-create-all']);
 
         foreach ($accessUrls as $index => $url) {
-            $this->actingAs($this->user)->visit($url);
-            $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
-            $this->seePageIs($expectedUrl);
+            $resp = $this->actingAs($this->user)->get($url);
+            $expectedUrl = Page::query()->where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+            $resp->assertRedirect($expectedUrl);
         }
 
-        $this->visit($createUrl)
-            ->type('test page', 'name')
-            ->type('page desc', 'html')
-            ->press('Save Page')
-            ->seePageIs($book->getUrl('/page/test-page'));
+        $this->get($createUrl);
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $this->post($draft->getUrl(), [
+            'name' => 'test page',
+            'html' => 'page desc',
+        ])->assertRedirect($book->getUrl('/page/test-page'));
 
-        $this->visit($chapter->getUrl('/create-page'))
-            ->type('new test page', 'name')
-            ->type('page desc', 'html')
-            ->press('Save Page')
-            ->seePageIs($book->getUrl('/page/new-test-page'));
+        $this->get($chapter->getUrl('/create-page'));
+        /** @var Page $draft */
+        $draft = Page::query()->where('draft', '=', true)->orderByDesc('id')->first();
+        $this->post($draft->getUrl(), [
+            'name' => 'new test page',
+            'html' => 'page desc',
+        ])->assertRedirect($book->getUrl('/page/new-test-page'));
     }
 
     public function test_page_edit_own_permission()
     {
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->checkAccessPermission('page-update-own', [
-            $ownPage->getUrl() . '/edit'
+            $ownPage->getUrl() . '/edit',
         ], [
-            $ownPage->getUrl() => 'Edit'
+            $ownPage->getUrl() => 'Edit',
         ]);
 
-        $this->visit($otherPage->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Edit')
-            ->visit($otherPage->getUrl() . '/edit')
-            ->seePageIs('/');
+        $this->get($otherPage->getUrl())->assertElementNotContains('.action-buttons', 'Edit');
+        $this->get($otherPage->getUrl() . '/edit')->assertRedirect('/');
     }
 
     public function test_page_edit_all_permission()
     {
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
         $this->checkAccessPermission('page-update-all', [
-            $otherPage->getUrl() . '/edit'
+            $otherPage->getUrl('/edit'),
         ], [
-            $otherPage->getUrl() => 'Edit'
+            $otherPage->getUrl() => 'Edit',
         ]);
     }
 
     public function test_page_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['page-update-all']);
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->checkAccessPermission('page-delete-own', [
-            $ownPage->getUrl() . '/delete'
+            $ownPage->getUrl() . '/delete',
         ], [
-            $ownPage->getUrl() => 'Delete'
+            $ownPage->getUrl() => 'Delete',
         ]);
 
         $parent = $ownPage->chapter ?? $ownPage->book;
-        $this->visit($otherPage->getUrl())
-            ->dontSeeInElement('.action-buttons', 'Delete')
-            ->visit($otherPage->getUrl() . '/delete')
-            ->seePageIs('/');
-        $this->visit($ownPage->getUrl())->visit($ownPage->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($parent->getUrl())
-            ->dontSeeInElement('.book-content', $ownPage->name);
+        $this->get($otherPage->getUrl())->assertElementNotContains('.action-buttons', 'Delete');
+        $this->get($otherPage->getUrl('/delete'))->assertRedirect('/');
+        $this->get($ownPage->getUrl());
+        $this->delete($ownPage->getUrl())->assertRedirect($parent->getUrl());
+        $this->get($parent->getUrl())->assertElementNotContains('.book-content', $ownPage->name);
     }
 
     public function test_page_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['page-update-all']);
-        $otherPage = Page::take(1)->get()->first();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->first();
+
         $this->checkAccessPermission('page-delete-all', [
-            $otherPage->getUrl() . '/delete'
+            $otherPage->getUrl() . '/delete',
         ], [
-            $otherPage->getUrl() => 'Delete'
+            $otherPage->getUrl() => 'Delete',
         ]);
 
+        /** @var Entity $parent */
         $parent = $otherPage->chapter ?? $otherPage->book;
-        $this->visit($otherPage->getUrl())->visit($otherPage->getUrl() . '/delete')
-            ->press('Confirm')
-            ->seePageIs($parent->getUrl())
-            ->dontSeeInElement('.book-content', $otherPage->name);
+        $this->get($otherPage->getUrl());
+
+        $this->delete($otherPage->getUrl())->assertRedirect($parent->getUrl());
+        $this->get($parent->getUrl())->assertDontSee($otherPage->name);
     }
 
     public function test_public_role_visible_in_user_edit_screen()
     {
-        $user = User::first();
+        /** @var User $user */
+        $user = User::query()->first();
         $adminRole = Role::getSystemRole('admin');
         $publicRole = Role::getSystemRole('public');
-        $this->asAdmin()->visit('/settings/users/' . $user->id)
-            ->seeElement('[name="roles['.$adminRole->id.']"]')
-            ->seeElement('[name="roles['.$publicRole->id.']"]');
+        $this->asAdmin()->get('/settings/users/' . $user->id)
+            ->assertElementExists('[name="roles[' . $adminRole->id . ']"]')
+            ->assertElementExists('[name="roles[' . $publicRole->id . ']"]');
     }
 
     public function test_public_role_visible_in_role_listing()
     {
-        $this->asAdmin()->visit('/settings/roles')
-            ->see('Admin')
-            ->see('Public');
+        $this->asAdmin()->get('/settings/roles')
+            ->assertSee('Admin')
+            ->assertSee('Public');
     }
 
     public function test_public_role_visible_in_default_role_setting()
     {
-        $this->asAdmin()->visit('/settings')
-            ->seeElement('[data-system-role-name="admin"]')
-            ->seeElement('[data-system-role-name="public"]');
+        $this->asAdmin()->get('/settings')
+            ->assertElementExists('[data-system-role-name="admin"]')
+            ->assertElementExists('[data-system-role-name="public"]');
     }
 
-    public function test_public_role_not_deleteable()
+    public function test_public_role_not_deletable()
     {
-        $this->asAdmin()->visit('/settings/roles')
-            ->click('Public')
-            ->see('Edit Role')
-            ->click('Delete Role')
-            ->press('Confirm')
-            ->see('Delete Role')
-            ->see('Cannot be deleted');
+        /** @var Role $publicRole */
+        $publicRole = Role::getSystemRole('public');
+        $resp = $this->asAdmin()->delete('/settings/roles/delete/' . $publicRole->id);
+        $resp->assertRedirect('/');
+
+        $this->get('/settings/roles/delete/' . $publicRole->id);
+        $resp = $this->delete('/settings/roles/delete/' . $publicRole->id);
+        $resp->assertRedirect('/settings/roles/delete/' . $publicRole->id);
+        $resp = $this->get('/settings/roles/delete/' . $publicRole->id);
+        $resp->assertSee('This role is a system role and cannot be deleted');
     }
 
     public function test_image_delete_own_permission()
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
-        $page = Page::first();
-        $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $image = Image::factory()->create([
+            'uploaded_to' => $page->id,
+            'created_by'  => $this->user->id,
+            'updated_by'  => $this->user->id,
+        ]);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(403);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['image-delete-own']);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(200)
-            ->dontSeeInDatabase('images', ['id' => $image->id]);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertOk();
+        $this->assertDatabaseMissing('images', ['id' => $image->id]);
     }
 
     public function test_image_delete_all_permission()
     {
         $this->giveUserPermissions($this->user, ['image-update-all']);
         $admin = $this->getAdmin();
-        $page = Page::first();
-        $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $image = Image::factory()->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(403);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['image-delete-own']);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(403);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['image-delete-all']);
 
-        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)
-            ->seeStatusCode(200)
-            ->dontSeeInDatabase('images', ['id' => $image->id]);
+        $this->actingAs($this->user)->json('delete', '/images/' . $image->id)->assertOk();
+        $this->assertDatabaseMissing('images', ['id' => $image->id]);
     }
 
     public function test_role_permission_removal()
     {
         // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
-        $page = Page::first();
+        /** @var Page $page */
+        $page = Page::query()->first();
         $viewerRole = Role::getRole('viewer');
         $viewer = $this->getViewer();
-        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(200);
+        $this->actingAs($viewer)->get($page->getUrl())->assertOk();
 
         $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [
             'display_name' => $viewerRole->display_name,
-            'description' => $viewerRole->description,
-            'permission' => []
-        ])->assertResponseStatus(302);
+            'description'  => $viewerRole->description,
+            'permission'   => [],
+        ])->assertStatus(302);
 
-        $this->expectException(HttpException::class);
-        $this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(404);
+        $this->actingAs($viewer)->get($page->getUrl())->assertStatus(404);
     }
 
     public function test_empty_state_actions_not_visible_without_permission()
     {
         $admin = $this->getAdmin();
         // Book links
-        $book = factory(Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
-        $this->updateEntityPermissions($book);
-        $this->actingAs($this->getViewer())->visit($book->getUrl())
-            ->dontSee('Create a new page')
-            ->dontSee('Add a chapter');
+        $book = Book::factory()->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
+        $this->regenEntityPermissions($book);
+        $this->actingAs($this->getViewer())->get($book->getUrl())
+            ->assertDontSee('Create a new page')
+            ->assertDontSee('Add a chapter');
 
         // Chapter links
-        $chapter = factory(Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
-        $this->updateEntityPermissions($chapter);
-        $this->actingAs($this->getViewer())->visit($chapter->getUrl())
-            ->dontSee('Create a new page')
-            ->dontSee('Sort the current book');
+        $chapter = Chapter::factory()->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
+        $this->regenEntityPermissions($chapter);
+        $this->actingAs($this->getViewer())->get($chapter->getUrl())
+            ->assertDontSee('Create a new page')
+            ->assertDontSee('Sort the current book');
     }
 
-    public function test_comment_create_permission () {
+    public function test_comment_create_permission()
+    {
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
 
-        $this->actingAs($this->user)->addComment($ownPage);
-
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)
+            ->addComment($ownPage)
+            ->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-create-all']);
 
-        $this->actingAs($this->user)->addComment($ownPage);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)
+            ->addComment($ownPage)
+            ->assertOk();
     }
 
-
-    public function test_comment_update_own_permission () {
+    public function test_comment_update_own_permission()
+    {
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->giveUserPermissions($this->user, ['comment-create-all']);
-        $commentId = $this->actingAs($this->user)->addComment($ownPage);
+        $this->actingAs($this->user)->addComment($ownPage);
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-update-own
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->updateComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-update-own']);
 
         // now has comment-update-own
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->updateComment($comment)->assertOk();
     }
 
-    public function test_comment_update_all_permission () {
+    public function test_comment_update_all_permission()
+    {
+        /** @var Page $ownPage */
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
-        $commentId = $this->asAdmin()->addComment($ownPage);
+        $this->asAdmin()->addComment($ownPage);
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-update-all
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->updateComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-update-all']);
 
         // now has comment-update-all
-        $this->actingAs($this->user)->updateComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->updateComment($comment)->assertOk();
     }
 
-    public function test_comment_delete_own_permission () {
+    public function test_comment_delete_own_permission()
+    {
+        /** @var Page $ownPage */
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
         $this->giveUserPermissions($this->user, ['comment-create-all']);
-        $commentId = $this->actingAs($this->user)->addComment($ownPage);
+        $this->actingAs($this->user)->addComment($ownPage);
+
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-delete-own
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->deleteComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-delete-own']);
 
         // now has comment-update-own
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->deleteComment($comment)->assertOk();
     }
 
-    public function test_comment_delete_all_permission () {
+    public function test_comment_delete_all_permission()
+    {
+        /** @var Page $ownPage */
         $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
-        $commentId = $this->asAdmin()->addComment($ownPage);
+        $this->asAdmin()->addComment($ownPage);
+        /** @var Comment $comment */
+        $comment = $ownPage->comments()->latest()->first();
 
         // no comment-delete-all
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(403);
+        $this->actingAs($this->user)->deleteComment($comment)->assertStatus(403);
 
         $this->giveUserPermissions($this->user, ['comment-delete-all']);
 
         // now has comment-delete-all
-        $this->actingAs($this->user)->deleteComment($commentId);
-        $this->assertResponseStatus(200);
+        $this->actingAs($this->user)->deleteComment($comment)->assertOk();
     }
 
-    private function addComment($page) {
-        $comment = factory(Comment::class)->make();
-        $url = "/comment/$page->id";
-        $request = [
-            'text' => $comment->text,
-            'html' => $comment->html
-        ];
+    private function addComment(Page $page): TestResponse
+    {
+        $comment = Comment::factory()->make();
 
-        $this->postJson($url, $request);
-        $comment = $page->comments()->first();
-        return $comment === null ? null : $comment->id;
+        return $this->postJson("/comment/$page->id", $comment->only('text', 'html'));
     }
 
-    private function updateComment($commentId) {
-        $comment = factory(Comment::class)->make();
-        $url = "/comment/$commentId";
-        $request = [
-            'text' => $comment->text,
-            'html' => $comment->html
-        ];
+    private function updateComment(Comment $comment): TestResponse
+    {
+        $commentData = Comment::factory()->make();
 
-        return $this->putJson($url, $request);
+        return $this->putJson("/comment/{$comment->id}", $commentData->only('text', 'html'));
     }
 
-    private function deleteComment($commentId) {
-         $url = '/comment/' . $commentId;
-         return $this->json('DELETE', $url);
+    private function deleteComment(Comment $comment): TestResponse
+    {
+        return $this->json('DELETE', '/comment/' . $comment->id);
     }
-
 }
index 7dbf467bd838d8d82ece92e72b6b298ec0b88146..499c0c9f9710ab0bbd4c6f7401743c325dc2c6be 100644 (file)
@@ -1,6 +1,7 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
-use Auth;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
@@ -8,11 +9,11 @@ use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\View;
 
 class PublicActionTest extends TestCase
 {
-
     public function test_app_not_public()
     {
         $this->setSettings(['app-public' => 'false']);
@@ -27,7 +28,7 @@ class PublicActionTest extends TestCase
     public function test_login_link_visible()
     {
         $this->setSettings(['app-public' => 'true']);
-        $this->get('/')->assertElementExists('a[href="'.url('/login').'"]');
+        $this->get('/')->assertElementExists('a[href="' . url('/login') . '"]');
     }
 
     public function test_register_link_visible_when_enabled()
@@ -94,22 +95,22 @@ class PublicActionTest extends TestCase
         $chapter = Chapter::query()->first();
         $resp = $this->get($chapter->getUrl());
         $resp->assertSee('New Page');
-        $resp->assertElementExists('a[href="'.$chapter->getUrl('/create-page').'"]');
+        $resp->assertElementExists('a[href="' . $chapter->getUrl('/create-page') . '"]');
 
         $resp = $this->get($chapter->getUrl('/create-page'));
         $resp->assertSee('Continue');
         $resp->assertSee('Page Name');
-        $resp->assertElementExists('form[action="'.$chapter->getUrl('/create-guest-page').'"]');
+        $resp->assertElementExists('form[action="' . $chapter->getUrl('/create-guest-page') . '"]');
 
         $resp = $this->post($chapter->getUrl('/create-guest-page'), ['name' => 'My guest page']);
         $resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit'));
 
         $user = User::getDefault();
         $this->assertDatabaseHas('pages', [
-            'name' => 'My guest page',
+            'name'       => 'My guest page',
             'chapter_id' => $chapter->id,
             'created_by' => $user->id,
-            'updated_by' => $user->id
+            'updated_by' => $user->id,
         ]);
     }
 
@@ -137,7 +138,7 @@ class PublicActionTest extends TestCase
 
         $resp = $this->get('/robots.txt');
         $resp->assertSee("User-agent: *\nDisallow:");
-        $resp->assertDontSee("Disallow: /");
+        $resp->assertDontSee('Disallow: /');
     }
 
     public function test_robots_effected_by_setting()
@@ -148,7 +149,7 @@ class PublicActionTest extends TestCase
 
         $resp = $this->get('/robots.txt');
         $resp->assertSee("User-agent: *\nDisallow:");
-        $resp->assertDontSee("Disallow: /");
+        $resp->assertDontSee('Disallow: /');
 
         // Check config overrides app-public setting
         config()->set('app.allow_robots', false);
@@ -184,4 +185,4 @@ class PublicActionTest extends TestCase
         $resp->assertRedirect($book->getUrl());
         $this->followRedirects($resp)->assertSee($book->name);
     }
-}
\ No newline at end of file
+}
index 55a9571de40acbdd1896cd7e5208012f8a28719d..f3e30c0d07d442f894347f7599e97e3e1c9218fc 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -6,8 +8,8 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
-use DB;
 use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
 
 class RecycleBinTest extends TestCase
 {
@@ -27,7 +29,7 @@ class RecycleBinTest extends TestCase
             "DELETE:/settings/recycle-bin/{$deletion->id}",
         ];
 
-        foreach($routes as $route) {
+        foreach ($routes as $route) {
             [$method, $url] = explode(':', $route);
             $resp = $this->call($method, $url);
             $this->assertPermissionError($resp);
@@ -35,7 +37,7 @@ class RecycleBinTest extends TestCase
 
         $this->giveUserPermissions($editor, ['restrictions-manage-all']);
 
-        foreach($routes as $route) {
+        foreach ($routes as $route) {
             [$method, $url] = explode(':', $route);
             $resp = $this->call($method, $url);
             $this->assertPermissionError($resp);
@@ -43,14 +45,13 @@ class RecycleBinTest extends TestCase
 
         $this->giveUserPermissions($editor, ['settings-manage']);
 
-        foreach($routes as $route) {
+        foreach ($routes as $route) {
             DB::beginTransaction();
             [$method, $url] = explode(':', $route);
             $resp = $this->call($method, $url);
             $this->assertNotPermissionError($resp);
             DB::rollBack();
         }
-
     }
 
     public function test_recycle_bin_view()
@@ -72,7 +73,7 @@ class RecycleBinTest extends TestCase
     public function test_recycle_bin_empty()
     {
         $page = Page::query()->first();
-        $book = Book::query()->where('id' , '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $book = Book::query()->where('id', '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
         $editor = $this->getEditor();
         $this->actingAs($editor)->delete($page->getUrl());
         $this->actingAs($editor)->delete($book->getUrl());
@@ -89,7 +90,7 @@ class RecycleBinTest extends TestCase
 
         $itemCount = 2 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
-        $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin');
+        $redirectReq->assertNotificationContains('Deleted ' . $itemCount . ' total items from the recycle bin');
     }
 
     public function test_entity_restore()
@@ -110,7 +111,7 @@ class RecycleBinTest extends TestCase
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
-        $redirectReq->assertNotificationContains('Restored '.$itemCount.' total items from the recycle bin');
+        $redirectReq->assertNotificationContains('Restored ' . $itemCount . ' total items from the recycle bin');
     }
 
     public function test_permanent_delete()
@@ -129,13 +130,13 @@ class RecycleBinTest extends TestCase
 
         $itemCount = 1 + $book->pages->count() + $book->chapters->count();
         $redirectReq = $this->get('/settings/recycle-bin');
-        $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin');
+        $redirectReq->assertNotificationContains('Deleted ' . $itemCount . ' total items from the recycle bin');
     }
 
     public function test_permanent_delete_for_each_type()
     {
         /** @var Entity $entity */
-        foreach ([new Bookshelf, new Book, new Chapter, new Page] as $entity) {
+        foreach ([new Bookshelf(), new Book(), new Chapter(), new Page()] as $entity) {
             $entity = $entity->newQuery()->first();
             $this->asEditor()->delete($entity->getUrl());
             $deletion = Deletion::query()->orderBy('id', 'desc')->firstOrFail();
@@ -154,24 +155,24 @@ class RecycleBinTest extends TestCase
         $deletion = $page->deletions()->firstOrFail();
 
         $this->assertDatabaseHas('activities', [
-            'type' => 'page_delete',
-            'entity_id' => $page->id,
+            'type'        => 'page_delete',
+            'entity_id'   => $page->id,
             'entity_type' => $page->getMorphClass(),
         ]);
 
         $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
 
         $this->assertDatabaseMissing('activities', [
-            'type' => 'page_delete',
-            'entity_id' => $page->id,
+            'type'        => 'page_delete',
+            'entity_id'   => $page->id,
             'entity_type' => $page->getMorphClass(),
         ]);
 
         $this->assertDatabaseHas('activities', [
-            'type' => 'page_delete',
-            'entity_id' => null,
+            'type'        => 'page_delete',
+            'entity_id'   => null,
             'entity_type' => null,
-            'detail' => $page->name,
+            'detail'      => $page->name,
         ]);
     }
 
@@ -233,8 +234,8 @@ class RecycleBinTest extends TestCase
         $chapterRestoreView->assertSeeText($chapter->name);
 
         $chapterRestore = $this->post("/settings/recycle-bin/{$chapterDeletion->id}/restore");
-        $chapterRestore->assertRedirect("/settings/recycle-bin");
-        $this->assertDatabaseMissing("deletions", ["id" => $chapterDeletion->id]);
+        $chapterRestore->assertRedirect('/settings/recycle-bin');
+        $this->assertDatabaseMissing('deletions', ['id' => $chapterDeletion->id]);
 
         $chapter->refresh();
         $this->assertNotNull($chapter->deleted_at);
@@ -247,4 +248,22 @@ class RecycleBinTest extends TestCase
         $chapter->refresh();
         $this->assertNull($chapter->deleted_at);
     }
-}
\ No newline at end of file
+
+    public function test_restore_page_shows_link_to_parent_restore_if_parent_also_deleted()
+    {
+        /** @var Book $book */
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $chapter = $book->chapters->first();
+        /** @var Page $page */
+        $page = $chapter->pages->first();
+        $this->asEditor()->delete($page->getUrl());
+        $this->asEditor()->delete($book->getUrl());
+
+        $bookDeletion = $book->deletions()->first();
+        $pageDeletion = $page->deletions()->first();
+
+        $pageRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$pageDeletion->id}/restore");
+        $pageRestoreView->assertSee('The parent of this item has also been deleted.');
+        $pageRestoreView->assertElementContains('a[href$="/settings/recycle-bin/' . $bookDeletion->id . '/restore"]', 'Restore Parent');
+    }
+}
index db095ff70d8d0f6f8214beb365bdd86f33c73999..78691badbbc5eb3e2b26ee140024636f98726450 100644 (file)
@@ -1,40 +1,40 @@
-<?php namespace Tests;
+<?php
 
+namespace Tests;
 
-use Illuminate\Support\Str;
+use BookStack\Util\CspService;
 
 class SecurityHeaderTest extends TestCase
 {
-
     public function test_cookies_samesite_lax_by_default()
     {
-        $resp = $this->get("/");
+        $resp = $this->get('/');
         foreach ($resp->headers->getCookies() as $cookie) {
-            $this->assertEquals("lax", $cookie->getSameSite());
+            $this->assertEquals('lax', $cookie->getSameSite());
         }
     }
 
     public function test_cookies_samesite_none_when_iframe_hosts_set()
     {
-        $this->runWithEnv("ALLOWED_IFRAME_HOSTS", "http://example.com", function() {
-            $resp = $this->get("/");
+        $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'http://example.com', function () {
+            $resp = $this->get('/');
             foreach ($resp->headers->getCookies() as $cookie) {
-                $this->assertEquals("none", $cookie->getSameSite());
+                $this->assertEquals('none', $cookie->getSameSite());
             }
         });
     }
 
     public function test_secure_cookies_controlled_by_app_url()
     {
-        $this->runWithEnv("APP_URL", "http://example.com", function() {
-            $resp = $this->get("/");
+        $this->runWithEnv('APP_URL', 'http://example.com', function () {
+            $resp = $this->get('/');
             foreach ($resp->headers->getCookies() as $cookie) {
                 $this->assertFalse($cookie->isSecure());
             }
         });
 
-        $this->runWithEnv("APP_URL", "https://example.com", function() {
-            $resp = $this->get("/");
+        $this->runWithEnv('APP_URL', 'https://example.com', function () {
+            $resp = $this->get('/');
             foreach ($resp->headers->getCookies() as $cookie) {
                 $this->assertTrue($cookie->isSecure());
             }
@@ -43,29 +43,100 @@ class SecurityHeaderTest extends TestCase
 
     public function test_iframe_csp_self_only_by_default()
     {
-        $resp = $this->get("/");
-        $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
-        $frameHeaders = $cspHeaders->filter(function ($val) {
-            return Str::startsWith($val, 'frame-ancestors');
-        });
+        $resp = $this->get('/');
+        $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
 
-        $this->assertTrue($frameHeaders->count() === 1);
-        $this->assertEquals('frame-ancestors \'self\'', $frameHeaders->first());
+        $this->assertEquals('frame-ancestors \'self\'', $frameHeader);
     }
 
     public function test_iframe_csp_includes_extra_hosts_if_configured()
     {
-        $this->runWithEnv("ALLOWED_IFRAME_HOSTS", "https://a.example.com https://b.example.com", function() {
-            $resp = $this->get("/");
-            $cspHeaders = collect($resp->headers->get('Content-Security-Policy'));
-            $frameHeaders = $cspHeaders->filter(function($val) {
-                return Str::startsWith($val, 'frame-ancestors');
-            });
-
-            $this->assertTrue($frameHeaders->count() === 1);
-            $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeaders->first());
+        $this->runWithEnv('ALLOWED_IFRAME_HOSTS', 'https://a.example.com https://b.example.com', function () {
+            $resp = $this->get('/');
+            $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
+
+            $this->assertNotEmpty($frameHeader);
+            $this->assertEquals('frame-ancestors \'self\' https://a.example.com https://b.example.com', $frameHeader);
         });
+    }
+
+    public function test_script_csp_set_on_responses()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertStringContainsString('\'strict-dynamic\'', $scriptHeader);
+        $this->assertStringContainsString('\'nonce-', $scriptHeader);
+    }
+
+    public function test_script_csp_nonce_matches_nonce_used_in_custom_head()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->get('/login');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+
+        $nonce = app()->make(CspService::class)->getNonce();
+        $this->assertStringContainsString('nonce-' . $nonce, $scriptHeader);
+        $resp->assertSee('<script nonce="' . $nonce . '">console.log("cat");</script>', false);
+    }
+
+    public function test_script_csp_nonce_changes_per_request()
+    {
+        $resp = $this->get('/');
+        $firstHeader = $this->getCspHeader($resp, 'script-src');
+
+        $this->refreshApplication();
 
+        $resp = $this->get('/');
+        $secondHeader = $this->getCspHeader($resp, 'script-src');
+
+        $this->assertNotEquals($firstHeader, $secondHeader);
     }
 
-}
\ No newline at end of file
+    public function test_allow_content_scripts_settings_controls_csp_script_headers()
+    {
+        config()->set('app.allow_content_scripts', true);
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertEmpty($scriptHeader);
+
+        config()->set('app.allow_content_scripts', false);
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'script-src');
+        $this->assertNotEmpty($scriptHeader);
+    }
+
+    public function test_object_src_csp_header_set()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'object-src');
+        $this->assertEquals('object-src \'self\'', $scriptHeader);
+    }
+
+    public function test_base_uri_csp_header_set()
+    {
+        $resp = $this->get('/');
+        $scriptHeader = $this->getCspHeader($resp, 'base-uri');
+        $this->assertEquals('base-uri \'self\'', $scriptHeader);
+    }
+
+    public function test_cache_control_headers_are_strict_on_responses_when_logged_in()
+    {
+        $this->asEditor();
+        $resp = $this->get('/');
+        $resp->assertHeader('Cache-Control', 'max-age=0, no-store, private');
+        $resp->assertHeader('Pragma', 'no-cache');
+        $resp->assertHeader('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
+    }
+
+    /**
+     * Get the value of the first CSP header of the given type.
+     */
+    protected function getCspHeader(TestResponse $resp, string $type): string
+    {
+        $cspHeaders = collect($resp->headers->all('Content-Security-Policy'));
+
+        return $cspHeaders->filter(function ($val) use ($type) {
+            return strpos($val, $type) === 0;
+        })->first() ?? '';
+    }
+}
diff --git a/tests/Settings/CustomHeadContentTest.php b/tests/Settings/CustomHeadContentTest.php
new file mode 100644 (file)
index 0000000..36c8a4c
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace Tests\Settings;
+
+use BookStack\Util\CspService;
+use Tests\TestCase;
+
+class CustomHeadContentTest extends TestCase
+{
+    public function test_configured_content_shows_on_pages()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->get('/login');
+        $resp->assertSee('console.log("cat")', false);
+    }
+
+    public function test_configured_content_does_not_show_on_settings_page()
+    {
+        $this->setSettings(['app-custom-head' => '<script>console.log("cat");</script>']);
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertDontSee('console.log("cat")', false);
+    }
+
+    public function test_divs_in_js_preserved_in_configured_content()
+    {
+        $this->setSettings(['app-custom-head' => '<script><div id="hello">cat</div></script>']);
+        $resp = $this->get('/login');
+        $resp->assertSee('<div id="hello">cat</div>', false);
+    }
+
+    public function test_nonce_application_handles_edge_cases()
+    {
+        $mockCSP = $this->mock(CspService::class);
+        $mockCSP->shouldReceive('getNonce')->andReturn('abc123');
+
+        $content = trim('
+<script>console.log("cat");</script>
+<script type="text/html"><\script>const a = `<div></div>`<\/\script></script>
+<script >const a = `<div></div>`;</script>
+<script type="<script text>test">const c = `<div></div>`;</script>
+<script
+    type="text/html"
+>
+const a = `<\script><\/script>`;
+const b = `<script`;
+</script>
+<SCRIPT>const b = `↗️£`;</SCRIPT>
+        ');
+
+        $expectedOutput = trim('
+<script nonce="abc123">console.log("cat");</script>
+<script type="text/html" nonce="abc123"><\script>const a = `<div></div>`<\/\script></script>
+<script nonce="abc123">const a = `<div></div>`;</script>
+<script type="&lt;script text&gt;test" nonce="abc123">const c = `<div></div>`;</script>
+<script type="text/html" nonce="abc123">
+const a = `<\script><\/script>`;
+const b = `<script`;
+</script>
+<script nonce="abc123">const b = `↗️£`;</script>
+        ');
+
+        $this->setSettings(['app-custom-head' => $content]);
+        $resp = $this->get('/login');
+        $resp->assertSee($expectedOutput, false);
+    }
+}
similarity index 86%
rename from tests/FooterLinksTest.php
rename to tests/Settings/FooterLinksTest.php
index f0ff0c40da5b946e617c174c5740559962228db7..f1b5d4294171b9df2392ed72aeda2ea983e1699b 100644 (file)
@@ -1,13 +1,14 @@
 <?php
 
+namespace Tests\Settings;
+
 use Tests\TestCase;
 
 class FooterLinksTest extends TestCase
 {
-
     public function test_saving_setting()
     {
-        $resp = $this->asAdmin()->post("/settings", [
+        $resp = $this->asAdmin()->post('/settings', [
             'setting-app-footer-links' => [
                 ['label' => 'My custom link 1', 'url' => 'https://example.com/1'],
                 ['label' => 'My custom link 2', 'url' => 'https://example.com/2'],
@@ -30,10 +31,10 @@ class FooterLinksTest extends TestCase
         ]]);
 
         $resp = $this->asAdmin()->get('/settings');
-        $resp->assertSee('value="My custom link"');
-        $resp->assertSee('value="Another Link"');
-        $resp->assertSee('value="https://example.com/link-a"');
-        $resp->assertSee('value="https://example.com/link-b"');
+        $resp->assertSee('value="My custom link"', false);
+        $resp->assertSee('value="Another Link"', false);
+        $resp->assertSee('value="https://example.com/link-a"', false);
+        $resp->assertSee('value="https://example.com/link-b"', false);
     }
 
     public function test_footer_links_show_on_pages()
@@ -58,4 +59,4 @@ class FooterLinksTest extends TestCase
         $resp->assertElementContains('footer a[href="https://example.com/privacy"]', 'Privacy Policy');
         $resp->assertElementContains('footer a[href="https://example.com/terms"]', 'Terms of Service');
     }
-}
\ No newline at end of file
+}
index 78c1f3b1825121a9c2a8ece027e29fe2fd087f3d..cbf49bf71f424f747e97bc04b3e3ff55fa6bb50b 100644 (file)
@@ -1,5 +1,11 @@
-<?php namespace Tests;
+<?php
 
+namespace Tests;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\Permissions\PermissionsRepo;
+use BookStack\Auth\Permissions\RolePermission;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -9,22 +15,24 @@ use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Repos\ChapterRepo;
-use BookStack\Auth\Permissions\PermissionsRepo;
-use BookStack\Auth\Role;
-use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Settings\SettingService;
 use BookStack\Uploads\HttpFetcher;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Env;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Testing\Assert as PHPUnit;
 use Mockery;
 use Monolog\Handler\TestHandler;
 use Monolog\Logger;
-use Illuminate\Foundation\Testing\Assert as PHPUnit;
+use Psr\Http\Client\ClientInterface;
 
 trait SharedTestHelpers
 {
-
     protected $admin;
     protected $editor;
 
@@ -57,7 +65,6 @@ trait SharedTestHelpers
         return $this->actingAs($this->getEditor());
     }
 
-
     /**
      * Get a editor user.
      */
@@ -67,6 +74,7 @@ trait SharedTestHelpers
             $editorRole = Role::getRole('editor');
             $this->editor = $editorRole->users->first();
         }
+
         return $this->editor;
     }
 
@@ -79,9 +87,18 @@ trait SharedTestHelpers
         if (!empty($attributes)) {
             $user->forceFill($attributes)->save();
         }
+
         return $user;
     }
 
+    /**
+     * Get a user that's not a system user such as the guest user.
+     */
+    public function getNormalUser(): User
+    {
+        return User::query()->where('system_name', '=', null)->get()->last();
+    }
+
     /**
      * Regenerate the permission for an entity.
      */
@@ -108,21 +125,22 @@ trait SharedTestHelpers
     }
 
     /**
-     * Create and return a new test chapter
+     * Create and return a new test chapter.
      */
-    public function newChapter(array $input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book): Chapter
+    public function newChapter(array $input, Book $book): Chapter
     {
         return app(ChapterRepo::class)->create($input, $book);
     }
 
     /**
-     * Create and return a new test page
+     * Create and return a new test page.
      */
     public function newPage(array $input = ['name' => 'test page', 'html' => 'My new test page']): Page
     {
         $book = Book::query()->first();
         $pageRepo = app(PageRepo::class);
         $draftPage = $pageRepo->getNewDraftPage($book);
+
         return $pageRepo->publishDraft($draftPage, $input);
     }
 
@@ -150,7 +168,7 @@ trait SharedTestHelpers
             foreach ($roles as $role) {
                 $permissions[] = [
                     'role_id' => $role->id,
-                    'action' => strtolower($action)
+                    'action'  => strtolower($action),
                 ];
             }
         }
@@ -173,17 +191,52 @@ trait SharedTestHelpers
         $user->clearPermissionCache();
     }
 
+    /**
+     * Completely remove the given permission name from the given user.
+     */
+    protected function removePermissionFromUser(User $user, string $permission)
+    {
+        $permission = RolePermission::query()->where('name', '=', $permission)->first();
+        /** @var Role $role */
+        foreach ($user->roles as $role) {
+            $role->detachPermission($permission);
+        }
+        $user->clearPermissionCache();
+    }
+
     /**
      * Create a new basic role for testing purposes.
      */
     protected function createNewRole(array $permissions = []): Role
     {
         $permissionRepo = app(PermissionsRepo::class);
-        $roleData = factory(Role::class)->make()->toArray();
+        $roleData = Role::factory()->make()->toArray();
         $roleData['permissions'] = array_flip($permissions);
+
         return $permissionRepo->saveNewRole($roleData);
     }
 
+    /**
+     * Create a group of entities that belong to a specific user.
+     *
+     * @return array{book: Book, chapter: Chapter, page: Page}
+     */
+    protected function createEntityChainBelongingToUser(User $creatorUser, ?User $updaterUser = null): array
+    {
+        if (empty($updaterUser)) {
+            $updaterUser = $creatorUser;
+        }
+
+        $userAttrs = ['created_by' => $creatorUser->id, 'owned_by' => $creatorUser->id, 'updated_by' => $updaterUser->id];
+        $book = Book::factory()->create($userAttrs);
+        $chapter = Chapter::factory()->create(array_merge(['book_id' => $book->id], $userAttrs));
+        $page = Page::factory()->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs));
+        $restrictionService = $this->app[PermissionService::class];
+        $restrictionService->buildJointPermissionsForEntity($book);
+
+        return compact('book', 'chapter', 'page');
+    }
+
     /**
      * Mock the HttpFetcher service and return the given data on fetch.
      */
@@ -196,6 +249,24 @@ trait SharedTestHelpers
             ->andReturn($returnData);
     }
 
+    /**
+     * Mock the http client used in BookStack.
+     * Returns a reference to the container which holds all history of http transactions.
+     *
+     * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
+     */
+    protected function &mockHttpClient(array $responses = []): array
+    {
+        $container = [];
+        $history = Middleware::history($container);
+        $mock = new MockHandler($responses);
+        $handlerStack = new HandlerStack($mock);
+        $handlerStack->push($history);
+        $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]);
+
+        return $container;
+    }
+
     /**
      * Run a set test with the given env variable.
      * Remembers the original and resets the value after test.
@@ -245,7 +316,7 @@ trait SharedTestHelpers
      */
     protected function assertPermissionError($response)
     {
-        PHPUnit::assertTrue($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response contains a permission error.");
+        PHPUnit::assertTrue($this->isPermissionError($response->baseResponse ?? $response->response), 'Failed asserting the response contains a permission error.');
     }
 
     /**
@@ -253,7 +324,7 @@ trait SharedTestHelpers
      */
     protected function assertNotPermissionError($response)
     {
-        PHPUnit::assertFalse($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response does not contain a permission error.");
+        PHPUnit::assertFalse($this->isPermissionError($response->baseResponse ?? $response->response), 'Failed asserting the response does not contain a permission error.');
     }
 
     /**
@@ -262,8 +333,26 @@ trait SharedTestHelpers
     private function isPermissionError($response): bool
     {
         return $response->status() === 302
-            && $response->headers->get('Location') === url('/')
-            && strpos(session()->pull('error', ''), 'You do not have permission to access') === 0;
+            && (
+                (
+                    $response->headers->get('Location') === url('/')
+                    && strpos(session()->pull('error', ''), 'You do not have permission to access') === 0
+                )
+                ||
+                (
+                    $response instanceof JsonResponse &&
+                    $response->json(['error' => 'You do not have permission to perform the requested action.'])
+                )
+            );
+    }
+
+    /**
+     * Assert that the session has a particular error notification message set.
+     */
+    protected function assertSessionError(string $message)
+    {
+        $error = session()->get('error');
+        PHPUnit::assertTrue($error === $message, "Failed asserting the session contains an error. \nFound: {$error}\nExpecting: {$message}");
     }
 
     /**
@@ -283,5 +372,4 @@ trait SharedTestHelpers
 
         return $testHandler;
     }
-
-}
\ No newline at end of file
+}
index b4c35cf91759774060a48a715d7ea594d54419da..82c377615c9adf8a5eebb0578eae1a70f07921e9 100644 (file)
@@ -1,21 +1,24 @@
 <?php
 
+namespace Tests;
+
+use Exception;
 use Illuminate\Cache\ArrayStore;
-use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Session;
-use Tests\TestCase;
+use Mockery;
 
 class StatusTest extends TestCase
 {
     public function test_returns_json_with_expected_results()
     {
-        $resp = $this->get("/status");
+        $resp = $this->get('/status');
         $resp->assertStatus(200);
         $resp->assertJson([
             'database' => true,
-            'cache' => true,
-            'session' => true,
+            'cache'    => true,
+            'session'  => true,
         ]);
     }
 
@@ -23,7 +26,7 @@ class StatusTest extends TestCase
     {
         DB::shouldReceive('table')->andThrow(new Exception());
 
-        $resp = $this->get("/status");
+        $resp = $this->get('/status');
         $resp->assertStatus(500);
         $resp->assertJson([
             'database' => false,
@@ -34,9 +37,9 @@ class StatusTest extends TestCase
     {
         $mockStore = Mockery::mock(new ArrayStore())->makePartial();
         Cache::swap($mockStore);
-        $mockStore->shouldReceive('get')->andReturn('cat');
+        $mockStore->shouldReceive('pull')->andReturn('cat');
 
-        $resp = $this->get("/status");
+        $resp = $this->get('/status');
         $resp->assertStatus(500);
         $resp->assertJson([
             'cache' => false,
@@ -50,10 +53,10 @@ class StatusTest extends TestCase
         Session::swap($mockSession);
         $mockSession->shouldReceive('get')->andReturn('cat');
 
-        $resp = $this->get("/status");
+        $resp = $this->get('/status');
         $resp->assertStatus(500);
         $resp->assertJson([
             'session' => false,
         ]);
     }
-}
\ No newline at end of file
+}
index 2c901981af53cb9e22909fdcc89673cb3cf5fece..98e0dfbacf4c4cfb6321a55d9aa44fe9e491c6ef 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 use BookStack\Entities\Models\Entity;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
@@ -12,25 +14,31 @@ abstract class TestCase extends BaseTestCase
 
     /**
      * The base URL to use while testing the application.
+     *
      * @var string
      */
     protected $baseUrl = 'http://localhost';
 
     /**
      * Assert the session contains a specific entry.
+     *
      * @param string $key
+     *
      * @return $this
      */
     protected function assertSessionHas(string $key)
     {
         $this->assertTrue(session()->has($key), "Session does not contain a [{$key}] entry");
+
         return $this;
     }
 
     /**
      * Override of the get method so we can get visibility of custom TestResponse methods.
-     * @param  string  $uri
-     * @param  array  $headers
+     *
+     * @param string $uri
+     * @param array  $headers
+     *
      * @return TestResponse
      */
     public function get($uri, array $headers = [])
@@ -41,7 +49,8 @@ abstract class TestCase extends BaseTestCase
     /**
      * Create the test response instance from the given response.
      *
-     * @param  \Illuminate\Http\Response $response
+     * @param \Illuminate\Http\Response $response
+     *
      * @return TestResponse
      */
     protected function createTestResponse($response)
@@ -53,7 +62,7 @@ abstract class TestCase extends BaseTestCase
      * Assert that an activity entry exists of the given key.
      * Checks the activity belongs to the given entity if provided.
      */
-    protected function assertActivityExists(string $type, Entity $entity = null)
+    protected function assertActivityExists(string $type, ?Entity $entity = null, string $detail = '')
     {
         $detailsToCheck = ['type' => $type];
 
@@ -62,6 +71,10 @@ abstract class TestCase extends BaseTestCase
             $detailsToCheck['entity_id'] = $entity->id;
         }
 
+        if ($detail) {
+            $detailsToCheck['detail'] = $detail;
+        }
+
         $this->assertDatabaseHas('activities', $detailsToCheck);
     }
-}
\ No newline at end of file
+}
index 76ff322fff6e0dae8ede4d175ef3e4b46ffa3c6b..e0350371afe00707ce8eb87f6597018e3f94f424 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 use BookStack\Notifications\TestEmail;
 use Illuminate\Contracts\Notifications\Dispatcher;
@@ -6,7 +8,6 @@ use Illuminate\Support\Facades\Notification;
 
 class TestEmailTest extends TestCase
 {
-
     public function test_a_send_test_button_shows()
     {
         $pageView = $this->asAdmin()->get('/settings/maintenance');
@@ -33,7 +34,7 @@ class TestEmailTest extends TestCase
         $this->app[Dispatcher::class] = $mockDispatcher;
 
         $exception = new \Exception('A random error occurred when testing an email');
-        $mockDispatcher->shouldReceive('send')->andThrow($exception);
+        $mockDispatcher->shouldReceive('sendNow')->andThrow($exception);
 
         $admin = $this->getAdmin();
         $sendReq = $this->actingAs($admin)->post('/settings/maintenance/send-test-email');
@@ -57,6 +58,4 @@ class TestEmailTest extends TestCase
         $sendReq = $this->actingAs($user)->post('/settings/maintenance/send-test-email');
         Notification::assertSentTo($user, TestEmail::class);
     }
-
-
-}
\ No newline at end of file
+}
index bf7ee0f69add042497b6862569354b1f1629427f..4e53aa020ba3ca5054e8d75e14a2ed57dc277276 100644 (file)
@@ -1,16 +1,17 @@
-<?php namespace Tests;
+<?php
 
-use \Illuminate\Foundation\Testing\TestResponse as BaseTestResponse;
-use Symfony\Component\DomCrawler\Crawler;
+namespace Tests;
+
+use Illuminate\Testing\TestResponse as BaseTestResponse;
 use PHPUnit\Framework\Assert as PHPUnit;
+use Symfony\Component\DomCrawler\Crawler;
 
 /**
  * Class TestResponse
  * Custom extension of the default Laravel TestResponse class.
- * @package Tests
  */
-class TestResponse extends BaseTestResponse {
-
+class TestResponse extends BaseTestResponse
+{
     protected $crawlerInstance;
 
     /**
@@ -21,11 +22,21 @@ class TestResponse extends BaseTestResponse {
         if (!is_object($this->crawlerInstance)) {
             $this->crawlerInstance = new Crawler($this->getContent());
         }
+
         return $this->crawlerInstance;
     }
 
+    /**
+     * Get the HTML of the first element at the given selector.
+     */
+    public function getElementHtml(string $selector): string
+    {
+        return $this->crawler()->filter($selector)->first()->outerHtml();
+    }
+
     /**
      * Assert the response contains the specified element.
+     *
      * @return $this
      */
     public function assertElementExists(string $selector)
@@ -33,16 +44,38 @@ class TestResponse extends BaseTestResponse {
         $elements = $this->crawler()->filter($selector);
         PHPUnit::assertTrue(
             $elements->count() > 0,
-            'Unable to find element matching the selector: '.PHP_EOL.PHP_EOL.
-            "[{$selector}]".PHP_EOL.PHP_EOL.
-            'within'.PHP_EOL.PHP_EOL.
+            'Unable to find element matching the selector: ' . PHP_EOL . PHP_EOL .
+            "[{$selector}]" . PHP_EOL . PHP_EOL .
+            'within' . PHP_EOL . PHP_EOL .
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Assert the response contains the given count of elements
+     * that match the given css selector.
+     *
+     * @return $this
+     */
+    public function assertElementCount(string $selector, int $count)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() === $count,
+            'Unable to ' . $count . ' element(s) matching the selector: ' . PHP_EOL . PHP_EOL .
+            "[{$selector}]" . PHP_EOL . PHP_EOL .
+            'found ' . $elements->count() . ' within' . PHP_EOL . PHP_EOL .
             "[{$this->getContent()}]."
         );
+
         return $this;
     }
 
     /**
      * Assert the response does not contain the specified element.
+     *
      * @return $this
      */
     public function assertElementNotExists(string $selector)
@@ -50,11 +83,12 @@ class TestResponse extends BaseTestResponse {
         $elements = $this->crawler()->filter($selector);
         PHPUnit::assertTrue(
             $elements->count() === 0,
-            'Found elements matching the selector: '.PHP_EOL.PHP_EOL.
-            "[{$selector}]".PHP_EOL.PHP_EOL.
-            'within'.PHP_EOL.PHP_EOL.
+            'Found elements matching the selector: ' . PHP_EOL . PHP_EOL .
+            "[{$selector}]" . PHP_EOL . PHP_EOL .
+            'within' . PHP_EOL . PHP_EOL .
             "[{$this->getContent()}]."
         );
+
         return $this;
     }
 
@@ -62,6 +96,7 @@ class TestResponse extends BaseTestResponse {
      * Assert the response includes a specific element containing the given text.
      * If an nth match is provided, only that will be checked otherwise all matching
      * elements will be checked for the given text.
+     *
      * @return $this
      */
     public function assertElementContains(string $selector, string $text, ?int $nthMatch = null)
@@ -84,12 +119,12 @@ class TestResponse extends BaseTestResponse {
 
         PHPUnit::assertTrue(
             $matched,
-            'Unable to find element of selector: '.PHP_EOL.PHP_EOL.
-            ($nthMatch ? ("at position {$nthMatch}".PHP_EOL.PHP_EOL) : '') .
-            "[{$selector}]".PHP_EOL.PHP_EOL.
-            'containing text'.PHP_EOL.PHP_EOL.
-            "[{$text}]".PHP_EOL.PHP_EOL.
-            'within'.PHP_EOL.PHP_EOL.
+            'Unable to find element of selector: ' . PHP_EOL . PHP_EOL .
+            ($nthMatch ? ("at position {$nthMatch}" . PHP_EOL . PHP_EOL) : '') .
+            "[{$selector}]" . PHP_EOL . PHP_EOL .
+            'containing text' . PHP_EOL . PHP_EOL .
+            "[{$text}]" . PHP_EOL . PHP_EOL .
+            'within' . PHP_EOL . PHP_EOL .
             "[{$this->getContent()}]."
         );
 
@@ -100,6 +135,7 @@ class TestResponse extends BaseTestResponse {
      * Assert the response does not include a specific element containing the given text.
      * If an nth match is provided, only that will be checked otherwise all matching
      * elements will be checked for the given text.
+     *
      * @return $this
      */
     public function assertElementNotContains(string $selector, string $text, ?int $nthMatch = null)
@@ -122,12 +158,12 @@ class TestResponse extends BaseTestResponse {
 
         PHPUnit::assertTrue(
             !$matched,
-            'Found element of selector: '.PHP_EOL.PHP_EOL.
-            ($nthMatch ? ("at position {$nthMatch}".PHP_EOL.PHP_EOL) : '') .
-            "[{$selector}]".PHP_EOL.PHP_EOL.
-            'containing text'.PHP_EOL.PHP_EOL.
-            "[{$text}]".PHP_EOL.PHP_EOL.
-            'within'.PHP_EOL.PHP_EOL.
+            'Found element of selector: ' . PHP_EOL . PHP_EOL .
+            ($nthMatch ? ("at position {$nthMatch}" . PHP_EOL . PHP_EOL) : '') .
+            "[{$selector}]" . PHP_EOL . PHP_EOL .
+            'containing text' . PHP_EOL . PHP_EOL .
+            "[{$text}]" . PHP_EOL . PHP_EOL .
+            'within' . PHP_EOL . PHP_EOL .
             "[{$this->getContent()}]."
         );
 
@@ -136,6 +172,7 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Assert there's a notification within the view containing the given text.
+     *
      * @return $this
      */
     public function assertNotificationContains(string $text)
@@ -145,14 +182,15 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Get the escaped text pattern for the constraint.
+     *
      * @return string
      */
     protected function getEscapedPattern(string $text)
     {
         $rawPattern = preg_quote($text, '/');
         $escapedPattern = preg_quote(e($text), '/');
+
         return $rawPattern == $escapedPattern
             ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
     }
-
 }
index 7a0cd49cb54696353841012b605b3404565298fb..775be92fc371379186f3bfad71f7fa41d80a178e 100644 (file)
@@ -1,14 +1,22 @@
-<?php namespace Tests;
+<?php
 
-use BookStack\Auth\Access\SocialAuthService;
+namespace Tests;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\DispatchWebhookJob;
+use BookStack\Actions\Webhook;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Facades\Theme;
 use BookStack\Theming\ThemeEvents;
-use File;
+use Illuminate\Console\Command;
+use Illuminate\Http\Client\Request as HttpClientRequest;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Http;
 use League\CommonMark\ConfigurableEnvironmentInterface;
 
 class ThemeTest extends TestCase
@@ -50,6 +58,7 @@ class ThemeTest extends TestCase
         $callback = function ($environment) use (&$callbackCalled) {
             $this->assertInstanceOf(ConfigurableEnvironmentInterface::class, $environment);
             $callbackCalled = true;
+
             return $environment;
         };
         Theme::listen(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $callback);
@@ -148,19 +157,50 @@ class ThemeTest extends TestCase
         Theme::listen(ThemeEvents::AUTH_REGISTER, $callback);
         $this->setSettings(['registration-enabled' => 'true']);
 
-        $user = factory(User::class)->make();
-        $this->post('/register', ['email' => $user->email, 'name' => $user->name,  'password' => 'password']);
+        $user = User::factory()->make();
+        $this->post('/register', ['email' => $user->email, 'name' => $user->name, 'password' => 'password']);
 
         $this->assertCount(2, $args);
         $this->assertEquals('standard', $args[0]);
         $this->assertInstanceOf(User::class, $args[1]);
     }
 
+    public function test_event_webhook_call_before()
+    {
+        $args = [];
+        $callback = function (...$eventArgs) use (&$args) {
+            $args = $eventArgs;
+
+            return ['test' => 'hello!'];
+        };
+        Theme::listen(ThemeEvents::WEBHOOK_CALL_BEFORE, $callback);
+
+        Http::fake([
+            '*' => Http::response('', 200),
+        ]);
+
+        $webhook = new Webhook(['name' => 'Test webhook', 'endpoint' => 'https://example.com']);
+        $webhook->save();
+        $event = ActivityType::PAGE_UPDATE;
+        $detail = Page::query()->first();
+
+        dispatch((new DispatchWebhookJob($webhook, $event, $detail)));
+
+        $this->assertCount(3, $args);
+        $this->assertEquals($event, $args[0]);
+        $this->assertEquals($webhook->id, $args[1]->id);
+        $this->assertEquals($detail->id, $args[2]->id);
+
+        Http::assertSent(function (HttpClientRequest $request) {
+            return $request->isJson() && $request->data()['test'] === 'hello!';
+        });
+    }
+
     public function test_add_social_driver()
     {
         Theme::addSocialDriver('catnet', [
-            'client_id' => 'abc123',
-            'client_secret' => 'def456'
+            'client_id'     => 'abc123',
+            'client_secret' => 'def456',
         ], 'SocialiteProviders\Discord\DiscordExtendSocialite@handleTesting');
 
         $this->assertEquals('catnet', config('services.catnet.name'));
@@ -174,9 +214,9 @@ class ThemeTest extends TestCase
     public function test_add_social_driver_uses_name_in_config_if_given()
     {
         Theme::addSocialDriver('catnet', [
-            'client_id' => 'abc123',
+            'client_id'     => 'abc123',
             'client_secret' => 'def456',
-            'name' => 'Super Cat Name',
+            'name'          => 'Super Cat Name',
         ], 'SocialiteProviders\Discord\DiscordExtendSocialite@handleTesting');
 
         $this->assertEquals('Super Cat Name', config('services.catnet.name'));
@@ -184,10 +224,40 @@ class ThemeTest extends TestCase
         $loginResp->assertSee('Super Cat Name');
     }
 
+    public function test_add_social_driver_allows_a_configure_for_redirect_callback_to_be_passed()
+    {
+        Theme::addSocialDriver(
+            'discord',
+            [
+                'client_id'     => 'abc123',
+                'client_secret' => 'def456',
+                'name'          => 'Super Cat Name',
+            ],
+            'SocialiteProviders\Discord\DiscordExtendSocialite@handle',
+            function ($driver) {
+                $driver->with(['donkey' => 'donut']);
+            }
+        );
+
+        $loginResp = $this->get('/login/service/discord');
+        $redirect = $loginResp->headers->get('location');
+        $this->assertStringContainsString('donkey=donut', $redirect);
+    }
+
+    public function test_register_command_allows_provided_command_to_be_usable_via_artisan()
+    {
+        Theme::registerCommand(new MyCustomCommand());
+
+        Artisan::call('bookstack:test-custom-command', []);
+        $output = Artisan::output();
+
+        $this->assertStringContainsString('Command ran!', $output);
+    }
+
     protected function usingThemeFolder(callable $callback)
     {
         // Create a folder and configure a theme
-        $themeFolderName = 'testing_theme_' . rtrim(base64_encode(time()), "=");
+        $themeFolderName = 'testing_theme_' . rtrim(base64_encode(time()), '=');
         config()->set('view.theme', $themeFolderName);
         $themeFolderPath = theme_path('');
         File::makeDirectory($themeFolderPath);
@@ -197,5 +267,14 @@ class ThemeTest extends TestCase
         // Cleanup the custom theme folder we created
         File::deleteDirectory($themeFolderPath);
     }
+}
 
-}
\ No newline at end of file
+class MyCustomCommand extends Command
+{
+    protected $signature = 'bookstack:test-custom-command';
+
+    public function handle()
+    {
+        $this->line('Command ran!');
+    }
+}
index 1d4decc2b330036feed751d396e52215770128ef..8c5b43810b38642698b620523d55b7a689eabf5f 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Unit;
+<?php
+
+namespace Tests\Unit;
 
 use Illuminate\Support\Facades\Log;
 use Tests\TestCase;
@@ -7,15 +9,12 @@ use Tests\TestCase;
  * Class ConfigTest
  * Many of the tests here are to check on tweaks made
  * to maintain backwards compatibility.
- *
- * @package Tests
  */
 class ConfigTest extends TestCase
 {
-
     public function test_filesystem_images_falls_back_to_storage_type_var()
     {
-        $this->runWithEnv('STORAGE_TYPE', 'local_secure', function() {
+        $this->runWithEnv('STORAGE_TYPE', 'local_secure', function () {
             $this->checkEnvConfigResult('STORAGE_IMAGE_TYPE', 's3', 'filesystems.images', 's3');
             $this->checkEnvConfigResult('STORAGE_IMAGE_TYPE', null, 'filesystems.images', 'local_secure');
         });
@@ -23,7 +22,7 @@ class ConfigTest extends TestCase
 
     public function test_filesystem_attachments_falls_back_to_storage_type_var()
     {
-        $this->runWithEnv('STORAGE_TYPE', 'local_secure', function() {
+        $this->runWithEnv('STORAGE_TYPE', 'local_secure', function () {
             $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', 's3', 'filesystems.attachments', 's3');
             $this->checkEnvConfigResult('STORAGE_ATTACHMENT_TYPE', null, 'filesystems.attachments', 'local_secure');
         });
@@ -46,11 +45,11 @@ class ConfigTest extends TestCase
         ]);
 
         $temp = tempnam(sys_get_temp_dir(), 'bs-test');
-        $original = ini_set( 'error_log', $temp);
+        $original = ini_set('error_log', $temp);
 
         Log::channel('errorlog_plain_webserver')->info('Aww, look, a cute puppy');
 
-        ini_set( 'error_log', $original);
+        ini_set('error_log', $original);
 
         $output = file_get_contents($temp);
         $this->assertStringContainsString('Aww, look, a cute puppy', $output);
@@ -67,16 +66,47 @@ class ConfigTest extends TestCase
         $this->checkEnvConfigResult('APP_URL', '', 'session.path', '/');
     }
 
+    public function test_saml2_idp_authn_context_string_parsed_as_space_separated_array()
+    {
+        $this->checkEnvConfigResult(
+            'SAML2_IDP_AUTHNCONTEXT',
+            'urn:federation:authentication:windows urn:federation:authentication:linux',
+            'saml2.onelogin.security.requestedAuthnContext',
+            ['urn:federation:authentication:windows', 'urn:federation:authentication:linux']
+        );
+    }
+
+    public function test_dompdf_remote_fetching_controlled_by_allow_untrusted_server_fetching_false()
+    {
+        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'false', 'dompdf.defines.enable_remote', false);
+        $this->checkEnvConfigResult('ALLOW_UNTRUSTED_SERVER_FETCHING', 'true', 'dompdf.defines.enable_remote', true);
+    }
+
+    public function test_dompdf_paper_size_options_are_limited()
+    {
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'dompdf.defines.default_paper_size', 'a4');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'dompdf.defines.default_paper_size', 'letter');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'dompdf.defines.default_paper_size', 'a4');
+    }
+
+    public function test_snappy_paper_size_options_are_limited()
+    {
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'cat', 'snappy.pdf.options.page-size', 'A4');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'letter', 'snappy.pdf.options.page-size', 'Letter');
+        $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'snappy.pdf.options.page-size', 'A4');
+    }
+
     /**
      * Set an environment variable of the given name and value
      * then check the given config key to see if it matches the given result.
      * Providing a null $envVal clears the variable.
+     *
+     * @param mixed $expectedResult
      */
-    protected function checkEnvConfigResult(string $envName, ?string $envVal, string $configKey, string $expectedResult)
+    protected function checkEnvConfigResult(string $envName, ?string $envVal, string $configKey, $expectedResult)
     {
-        $this->runWithEnv($envName, $envVal, function() use ($configKey, $expectedResult) {
+        $this->runWithEnv($envName, $envVal, function () use ($configKey, $expectedResult) {
             $this->assertEquals($expectedResult, config($configKey));
         });
     }
-
-}
\ No newline at end of file
+}
diff --git a/tests/Unit/FrameworkAssumptionTest.php b/tests/Unit/FrameworkAssumptionTest.php
new file mode 100644 (file)
index 0000000..54d315d
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace Tests\Unit;
+
+use BadMethodCallException;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+/**
+ * This class tests assumptions we're relying upon in the framework.
+ * This is primarily to keep track of certain bits of functionality that
+ * may be used in important areas such as to enforce permissions.
+ */
+class FrameworkAssumptionTest extends TestCase
+{
+    public function test_scopes_error_if_not_existing()
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call to undefined method BookStack\Entities\Models\Page::scopeNotfoundscope()');
+        Page::query()->scopes('notfoundscope');
+    }
+
+    public function test_scopes_applies_upon_existing()
+    {
+        // Page has SoftDeletes trait by default, so we apply our custom scope and ensure
+        // it stacks on the global scope to filter out deleted items.
+        $query = Page::query()->scopes('visible')->toSql();
+        $this->assertStringContainsString('joint_permissions', $query);
+        $this->assertStringContainsString('`deleted_at` is null', $query);
+    }
+}
diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php
new file mode 100644 (file)
index 0000000..ad91eec
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+
+namespace Tests\Unit;
+
+use BookStack\Auth\Access\Oidc\OidcIdToken;
+use BookStack\Auth\Access\Oidc\OidcInvalidTokenException;
+use Tests\Helpers\OidcJwtHelper;
+use Tests\TestCase;
+
+class OidcIdTokenTest extends TestCase
+{
+    public function test_valid_token_passes_validation()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [
+            OidcJwtHelper::publicJwkKeyArray(),
+        ]);
+
+        $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123'));
+    }
+
+    public function test_get_claim_returns_value_if_existing()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
+        $this->assertEquals('[email protected]', $token->getClaim('email'));
+    }
+
+    public function test_get_claim_returns_null_if_not_existing()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
+        $this->assertEquals(null, $token->getClaim('emails'));
+    }
+
+    public function test_get_all_claims_returns_all_payload_claims()
+    {
+        $defaultPayload = OidcJwtHelper::defaultPayload();
+        $token = new OidcIdToken(OidcJwtHelper::idToken($defaultPayload), OidcJwtHelper::defaultIssuer(), []);
+        $this->assertEquals($defaultPayload, $token->getAllClaims());
+    }
+
+    public function test_token_structure_error_cases()
+    {
+        $idToken = OidcJwtHelper::idToken();
+        $idTokenExploded = explode('.', $idToken);
+
+        $messagesAndTokenValues = [
+            ['Could not parse out a valid header within the provided token', ''],
+            ['Could not parse out a valid header within the provided token', 'cat'],
+            ['Could not parse out a valid payload within the provided token', $idTokenExploded[0]],
+            ['Could not parse out a valid payload within the provided token', $idTokenExploded[0] . '.' . 'dog'],
+            ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1]],
+            ['Could not parse out a valid signature within the provided token', $idTokenExploded[0] . '.' . $idTokenExploded[1] . '.' . '@$%'],
+        ];
+
+        foreach ($messagesAndTokenValues as [$message, $tokenValue]) {
+            $token = new OidcIdToken($tokenValue, OidcJwtHelper::defaultIssuer(), []);
+            $err = null;
+
+            try {
+                $token->validate('abc');
+            } catch (\Exception $exception) {
+                $err = $exception;
+            }
+
+            $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message);
+            $this->assertEquals($message, $err->getMessage());
+        }
+    }
+
+    public function test_error_thrown_if_token_signature_not_validated_from_no_keys()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), []);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Token signature could not be validated using the provided keys');
+        $token->validate('abc');
+    }
+
+    public function test_error_thrown_if_token_signature_not_validated_from_non_matching_key()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [
+            array_merge(OidcJwtHelper::publicJwkKeyArray(), [
+                'n' => 'iqK-1QkICMf_cusNLpeNnN-bhT0-9WLBvzgwKLALRbrevhdi5ttrLHIQshaSL0DklzfyG2HWRmAnJ9Q7sweEjuRiiqRcSUZbYu8cIv2hLWYu7K_NH67D2WUjl0EnoHEuiVLsZhQe1CmdyLdx087j5nWkd64K49kXRSdxFQUlj8W3NeK3CjMEUdRQ3H4RZzJ4b7uuMiFA29S2ZhMNG20NPbkUVsFL-jiwTd10KSsPT8yBYipI9O7mWsUWt_8KZs1y_vpM_k3SyYihnWpssdzDm1uOZ8U3mzFr1xsLAO718GNUSXk6npSDzLl59HEqa6zs4O9awO2qnSHvcmyELNk31w',
+            ]),
+        ]);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Token signature could not be validated using the provided keys');
+        $token->validate('abc');
+    }
+
+    public function test_error_thrown_if_invalid_key_provided()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), ['url://example.com']);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Unexpected type of key value provided');
+        $token->validate('abc');
+    }
+
+    public function test_error_thrown_if_token_algorithm_is_not_rs256()
+    {
+        $token = new OidcIdToken(OidcJwtHelper::idToken([], ['alg' => 'HS256']), OidcJwtHelper::defaultIssuer(), []);
+        $this->expectException(OidcInvalidTokenException::class);
+        $this->expectExceptionMessage('Only RS256 signature validation is supported. Token reports using HS256');
+        $token->validate('abc');
+    }
+
+    public function test_token_claim_error_cases()
+    {
+        /** @var array<array{0: string: 1: array}> $claimOverridesByErrorMessage */
+        $claimOverridesByErrorMessage = [
+            // 1. iss claim present
+            ['Missing or non-matching token issuer value', ['iss' => null]],
+            // 1. iss claim matches provided issuer
+            ['Missing or non-matching token issuer value', ['iss' => 'https://auth.example.co.uk']],
+            // 2. aud claim present
+            ['Missing token audience value', ['aud' => null]],
+            // 2. aud claim validates all values against those expected (Only expect single)
+            ['Token audience value has 2 values, Expected 1', ['aud' => ['abc', 'def']]],
+            // 2. aud claim matches client id
+            ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']],
+            // 4. azp claim matches client id if present
+            ['Token authorized party exists but does not match the expected client_id', ['azp' => 'xxyyzz.aaa.bbccdd.456']],
+            // 5. exp claim present
+            ['Missing token expiration time value', ['exp' => null]],
+            // 5. exp claim not expired
+            ['Token has expired', ['exp' => time() - 360]],
+            // 6. iat claim present
+            ['Missing token issued at time value', ['iat' => null]],
+            // 6. iat claim too far in the future
+            ['Token issue at time is not recent or is invalid', ['iat' => time() + 600]],
+            // 6. iat claim too far in the past
+            ['Token issue at time is not recent or is invalid', ['iat' => time() - 172800]],
+
+            // Custom: sub is present
+            ['Missing token subject value', ['sub' => null]],
+        ];
+
+        foreach ($claimOverridesByErrorMessage as [$message, $overrides]) {
+            $token = new OidcIdToken(OidcJwtHelper::idToken($overrides), OidcJwtHelper::defaultIssuer(), [
+                OidcJwtHelper::publicJwkKeyArray(),
+            ]);
+
+            $err = null;
+
+            try {
+                $token->validate('xxyyzz.aaa.bbccdd.123');
+            } catch (\Exception $exception) {
+                $err = $exception;
+            }
+
+            $this->assertInstanceOf(OidcInvalidTokenException::class, $err, $message);
+            $this->assertEquals($message, $err->getMessage());
+        }
+    }
+
+    public function test_keys_can_be_a_local_file_reference_to_pem_key()
+    {
+        $file = tmpfile();
+        $testFilePath = 'file://' . stream_get_meta_data($file)['uri'];
+        file_put_contents($testFilePath, OidcJwtHelper::publicPemKey());
+        $token = new OidcIdToken(OidcJwtHelper::idToken(), OidcJwtHelper::defaultIssuer(), [
+            $testFilePath,
+        ]);
+
+        $this->assertTrue($token->validate('xxyyzz.aaa.bbccdd.123'));
+        unlink($testFilePath);
+    }
+}
index b9f485da13f0743178049d2e314db04fccd18536..fff5414f2bd45df1aec13c8be529ae462b3a3001 100644 (file)
@@ -1,22 +1,22 @@
-<?php namespace Tests\Unit;
+<?php
+
+namespace Tests\Unit;
 
 use Tests\TestCase;
 
 class UrlTest extends TestCase
 {
-
     public function test_url_helper_takes_custom_url_into_account()
     {
-        $this->runWithEnv('APP_URL', 'http://example.com/bookstack', function() {
+        $this->runWithEnv('APP_URL', 'http://example.com/bookstack', function () {
             $this->assertEquals('http://example.com/bookstack/books', url('/books'));
         });
     }
 
     public function test_url_helper_sets_correct_scheme_even_when_request_scheme_is_different()
     {
-        $this->runWithEnv('APP_URL', 'https://example.com/', function() {
+        $this->runWithEnv('APP_URL', 'https://example.com/', function () {
             $this->get('http://example.com/login')->assertSee('https://example.com/dist/styles.css');
         });
     }
-
-}
\ No newline at end of file
+}
index 1ca9ea23b17d5d04101c2203173d394b3455b379..5545edf13255d1bf1df24e8c7e4e370b0f21f545 100644 (file)
@@ -1,48 +1,64 @@
-<?php namespace Tests\Uploads;
+<?php
 
-use BookStack\Entities\Tools\TrashCan;
+namespace Tests\Uploads;
+
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Uploads\Attachment;
-use BookStack\Entities\Models\Page;
-use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Uploads\AttachmentService;
 use Illuminate\Http\UploadedFile;
 use Tests\TestCase;
-use Tests\TestResponse;
 
 class AttachmentTest extends TestCase
 {
     /**
-     * Get a test file that can be uploaded
+     * Get a test file that can be uploaded.
      */
     protected function getTestFile(string $fileName): UploadedFile
     {
-        return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
+        return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', null, true);
     }
 
     /**
      * Uploads a file with the given name.
      */
-    protected function uploadFile(string $name, int $uploadedTo = 0): \Illuminate\Foundation\Testing\TestResponse
+    protected function uploadFile(string $name, int $uploadedTo = 0): \Illuminate\Testing\TestResponse
     {
         $file = $this->getTestFile($name);
+
         return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
     }
 
     /**
-     * Create a new attachment
+     * Create a new attachment.
      */
     protected function createAttachment(Page $page): Attachment
     {
         $this->post('attachments/link', [
-            'attachment_link_url' => 'https://example.com',
-            'attachment_link_name' => 'Example Attachment Link',
+            'attachment_link_url'         => 'https://example.com',
+            'attachment_link_name'        => 'Example Attachment Link',
             'attachment_link_uploaded_to' => $page->id,
         ]);
 
         return Attachment::query()->latest()->first();
     }
 
+    /**
+     * Create a new upload attachment from the given data.
+     */
+    protected function createUploadAttachment(Page $page, string $filename, string $content, string $mimeType): Attachment
+    {
+        $file = tmpfile();
+        $filePath = stream_get_meta_data($file)['uri'];
+        file_put_contents($filePath, $content);
+        $upload = new UploadedFile($filePath, $filename, $mimeType, null, true);
+
+        $this->call('POST', '/attachments/upload', ['uploaded_to' => $page->id], [], ['file' => $upload], []);
+
+        return $page->attachments()->latest()->firstOrFail();
+    }
+
     /**
      * Delete all uploaded files.
      * To assist with cleanup.
@@ -57,16 +73,16 @@ class AttachmentTest extends TestCase
 
     public function test_file_upload()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $admin = $this->getAdmin();
         $fileName = 'upload_test_file.txt';
 
         $expectedResp = [
-            'name' => $fileName,
+            'name'       => $fileName,
             'uploaded_to'=> $page->id,
-            'extension' => 'txt',
-            'order' => 1,
+            'extension'  => 'txt',
+            'order'      => 1,
             'created_by' => $admin->id,
             'updated_by' => $admin->id,
         ];
@@ -75,9 +91,9 @@ class AttachmentTest extends TestCase
         $upload->assertStatus(200);
 
         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
-        $expectedResp['path'] = $attachment->path;
-
         $upload->assertJson($expectedResp);
+
+        $expectedResp['path'] = $attachment->path;
         $this->assertDatabaseHas('attachments', $expectedResp);
 
         $this->deleteUploads();
@@ -85,21 +101,21 @@ class AttachmentTest extends TestCase
 
     public function test_file_upload_does_not_use_filename()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $fileName = 'upload_test_file.txt';
 
-
         $upload = $this->asAdmin()->uploadFile($fileName, $page->id);
         $upload->assertStatus(200);
 
         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
         $this->assertStringNotContainsString($fileName, $attachment->path);
-        $this->assertStringEndsWith('.txt', $attachment->path);
+        $this->assertStringEndsWith('-txt', $attachment->path);
+        $this->deleteUploads();
     }
 
     public function test_file_display_and_access()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'upload_test_file.txt';
 
@@ -119,25 +135,25 @@ class AttachmentTest extends TestCase
 
     public function test_attaching_link_to_page()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->asAdmin();
 
         $linkReq = $this->call('POST', 'attachments/link', [
-            'attachment_link_url' => 'https://example.com',
-            'attachment_link_name' => 'Example Attachment Link',
+            'attachment_link_url'         => 'https://example.com',
+            'attachment_link_name'        => 'Example Attachment Link',
             'attachment_link_uploaded_to' => $page->id,
         ]);
 
         $expectedData = [
-            'path' => 'https://example.com',
-            'name' => 'Example Attachment Link',
+            'path'        => 'https://example.com',
+            'name'        => 'Example Attachment Link',
             'uploaded_to' => $page->id,
-            'created_by' => $admin->id,
-            'updated_by' => $admin->id,
-            'external' => true,
-            'order' => 1,
-            'extension' => ''
+            'created_by'  => $admin->id,
+            'updated_by'  => $admin->id,
+            'external'    => true,
+            'order'       => 1,
+            'extension'   => '',
         ];
 
         $linkReq->assertStatus(200);
@@ -156,20 +172,20 @@ class AttachmentTest extends TestCase
 
     public function test_attachment_updating()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
 
         $attachment = $this->createAttachment($page);
         $update = $this->call('PUT', 'attachments/' . $attachment->id, [
             'attachment_edit_name' => 'My new attachment name',
-            'attachment_edit_url' => 'https://test.example.com'
+            'attachment_edit_url'  => 'https://test.example.com',
         ]);
 
         $expectedData = [
-            'id' => $attachment->id,
-            'path' => 'https://test.example.com',
-            'name' => 'My new attachment name',
-            'uploaded_to' => $page->id
+            'id'          => $attachment->id,
+            'path'        => 'https://test.example.com',
+            'name'        => 'My new attachment name',
+            'uploaded_to' => $page->id,
         ];
 
         $update->assertStatus(200);
@@ -180,7 +196,7 @@ class AttachmentTest extends TestCase
 
     public function test_file_deletion()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'deletion_test.txt';
         $this->uploadFile($fileName, $page->id);
@@ -193,7 +209,7 @@ class AttachmentTest extends TestCase
         $this->delete($attachment->getUrl());
 
         $this->assertDatabaseMissing('attachments', [
-            'name' => $fileName
+            'name' => $fileName,
         ]);
         $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
 
@@ -202,7 +218,7 @@ class AttachmentTest extends TestCase
 
     public function test_attachment_deletion_on_page_deletion()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'deletion_test.txt';
         $this->uploadFile($fileName, $page->id);
@@ -212,14 +228,14 @@ class AttachmentTest extends TestCase
 
         $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
         $this->assertDatabaseHas('attachments', [
-            'name' => $fileName
+            'name' => $fileName,
         ]);
 
         app(PageRepo::class)->destroy($page);
         app(TrashCan::class)->empty();
 
         $this->assertDatabaseMissing('attachments', [
-            'name' => $fileName
+            'name' => $fileName,
         ]);
         $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
 
@@ -230,8 +246,7 @@ class AttachmentTest extends TestCase
     {
         $admin = $this->getAdmin();
         $viewer = $this->getViewer();
-        $page = Page::first(); /** @var Page $page */
-
+        $page = Page::query()->first(); /** @var Page $page */
         $this->actingAs($admin);
         $fileName = 'permission_test.txt';
         $this->uploadFile($fileName, $page->id);
@@ -246,14 +261,14 @@ class AttachmentTest extends TestCase
         $this->actingAs($viewer);
         $attachmentGet = $this->get($attachment->getUrl());
         $attachmentGet->assertStatus(404);
-        $attachmentGet->assertSee("Attachment not found");
+        $attachmentGet->assertSee('Attachment not found');
 
         $this->deleteUploads();
     }
 
     public function test_data_and_js_links_cannot_be_attached_to_a_page()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
 
         $badLinks = [
@@ -261,15 +276,15 @@ class AttachmentTest extends TestCase
             ' javascript:alert("bunny")',
             'JavaScript:alert("bunny")',
             "\t\n\t\nJavaScript:alert(\"bunny\")",
-            "data:text/html;<a></a>",
-            "Data:text/html;<a></a>",
-            "Data:text/html;<a></a>",
+            'data:text/html;<a></a>',
+            'Data:text/html;<a></a>',
+            'Data:text/html;<a></a>',
         ];
 
         foreach ($badLinks as $badLink) {
             $linkReq = $this->post('attachments/link', [
-                'attachment_link_url' => $badLink,
-                'attachment_link_name' => 'Example Attachment Link',
+                'attachment_link_url'         => $badLink,
+                'attachment_link_name'        => 'Example Attachment Link',
                 'attachment_link_uploaded_to' => $page->id,
             ]);
             $linkReq->assertStatus(422);
@@ -282,7 +297,7 @@ class AttachmentTest extends TestCase
 
         foreach ($badLinks as $badLink) {
             $linkReq = $this->put('attachments/' . $attachment->id, [
-                'attachment_edit_url' => $badLink,
+                'attachment_edit_url'  => $badLink,
                 'attachment_edit_name' => 'Example Attachment Link',
             ]);
             $linkReq->assertStatus(422);
@@ -291,4 +306,38 @@ class AttachmentTest extends TestCase
             ]);
         }
     }
+
+    public function test_file_access_with_open_query_param_provides_inline_response_with_correct_content_type()
+    {
+        $page = Page::query()->first();
+        $this->asAdmin();
+        $fileName = 'upload_test_file.txt';
+
+        $upload = $this->uploadFile($fileName, $page->id);
+        $upload->assertStatus(200);
+        $attachment = Attachment::query()->orderBy('id', 'desc')->take(1)->first();
+
+        $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-Disposition', 'inline; filename="upload_test_file.txt"');
+        $attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff');
+
+        $this->deleteUploads();
+    }
+
+    public function test_html_file_access_with_open_forces_plain_content_type()
+    {
+        $page = Page::query()->first();
+        $this->asAdmin();
+
+        $attachment = $this->createUploadAttachment($page, 'test_file.html', '<html></html><p>testing</p>', 'text/html');
+
+        $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-Disposition', 'inline; filename="test_file.html"');
+
+        $this->deleteUploads();
+    }
 }
index efaa016ddc6660ead3ea67d9a300ccd54cd75e4f..650f5b4ea359ac5dd02929c9780cb67a13031157 100644 (file)
@@ -1,25 +1,26 @@
-<?php namespace Tests\Uploads;
+<?php
+
+namespace Tests\Uploads;
 
 use BookStack\Auth\User;
 use BookStack\Exceptions\HttpFetchException;
 use BookStack\Uploads\HttpFetcher;
-use Illuminate\Support\Facades\Log;
 use Tests\TestCase;
 
 class AvatarTest extends TestCase
 {
     use UsesImages;
 
-
-    protected function createUserRequest($user)
+    protected function createUserRequest($user): User
     {
         $this->asAdmin()->post('/settings/users/create', [
-            'name' => $user->name,
-            'email' => $user->email,
-            'password' => 'testing',
-            'password-confirm' => 'testing',
+            'name'             => $user->name,
+            'email'            => $user->email,
+            'password'         => 'testing101',
+            'password-confirm' => 'testing101',
         ]);
-        return User::where('email', '=', $user->email)->first();
+
+        return User::query()->where('email', '=', $user->email)->first();
     }
 
     protected function assertImageFetchFrom(string $url)
@@ -41,27 +42,26 @@ class AvatarTest extends TestCase
         config()->set([
             'services.disable_services' => false,
         ]);
-        $user = factory(User::class)->make();
-        $this->assertImageFetchFrom('https://www.gravatar.com/avatar/'.md5(strtolower($user->email)).'?s=500&d=identicon');
+        $user = User::factory()->make();
+        $this->assertImageFetchFrom('https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon');
 
         $user = $this->createUserRequest($user);
         $this->assertDatabaseHas('images', [
-            'type' => 'user',
-            'created_by' => $user->id
+            'type'       => 'user',
+            'created_by' => $user->id,
         ]);
         $this->deleteUserImage($user);
     }
 
-
     public function test_custom_url_used_if_set()
     {
         config()->set([
             'services.disable_services' => false,
-            'services.avatar_url' => 'https://example.com/${email}/${hash}/${size}',
+            'services.avatar_url'       => 'https://example.com/${email}/${hash}/${size}',
         ]);
 
-        $user = factory(User::class)->make();
-        $url = 'https://example.com/'. urlencode(strtolower($user->email)) .'/'. md5(strtolower($user->email)).'/500';
+        $user = User::factory()->make();
+        $url = 'https://example.com/' . urlencode(strtolower($user->email)) . '/' . md5(strtolower($user->email)) . '/500';
         $this->assertImageFetchFrom($url);
 
         $user = $this->createUserRequest($user);
@@ -74,7 +74,7 @@ class AvatarTest extends TestCase
             'services.disable_services' => true,
         ]);
 
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
 
         $http = $this->mock(HttpFetcher::class);
         $http->shouldNotReceive('fetch');
@@ -93,9 +93,8 @@ class AvatarTest extends TestCase
 
         $logger = $this->withTestLogger();
 
-        $user = factory(User::class)->make();
+        $user = User::factory()->make();
         $this->createUserRequest($user);
         $this->assertTrue($logger->hasError('Failed to save user avatar image'));
     }
-
 }
index d134135aa6e9aed7a6f3fceb0a07d37702252ec0..1fc3d1049282f2552f4a74bcf3efdd9f1fa54d98 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Uploads;
+<?php
+
+namespace Tests\Uploads;
 
 use BookStack\Entities\Models\Page;
 use BookStack\Uploads\Image;
@@ -21,7 +23,7 @@ class DrawioTest extends TestCase
 
         $imageGet = $this->getJson("/images/drawio/base64/{$image->id}");
         $imageGet->assertJson([
-            'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII='
+            'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
         ]);
     }
 
@@ -33,23 +35,23 @@ class DrawioTest extends TestCase
 
         $upload = $this->postJson('images/drawio', [
             'uploaded_to' => $page->id,
-            'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII='
+            'image'       => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
         ]);
 
         $upload->assertStatus(200);
         $upload->assertJson([
-            'type' => 'drawio',
+            'type'        => 'drawio',
             'uploaded_to' => $page->id,
-            'created_by' => $editor->id,
-            'updated_by' => $editor->id,
+            'created_by'  => $editor->id,
+            'updated_by'  => $editor->id,
         ]);
 
         $image = Image::where('type', '=', 'drawio')->first();
-        $this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path));
+        $this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: ' . public_path($image->path));
 
         $testImageData = file_get_contents($this->getTestImageFilePath());
         $uploadedImageData = file_get_contents(public_path($image->path));
-        $this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected");
+        $this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
     }
 
     public function test_drawio_url_can_be_configured()
@@ -59,7 +61,7 @@ class DrawioTest extends TestCase
         $editor = $this->getEditor();
 
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
-        $resp->assertSee('drawio-url="http://cats.com?dog=tree"');
+        $resp->assertSee('drawio-url="http://cats.com?dog=tree"', false);
     }
 
     public function test_drawio_url_can_be_disabled()
@@ -69,11 +71,10 @@ class DrawioTest extends TestCase
         $editor = $this->getEditor();
 
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
-        $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&amp;proto=json&amp;spin=1"');
+        $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&amp;proto=json&amp;spin=1"', false);
 
         config()->set('services.drawio', false);
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
-        $resp->assertDontSee('drawio-url');
+        $resp->assertDontSee('drawio-url', false);
     }
-
-}
\ No newline at end of file
+}
index c03d15dd751a3d044ecdff00c0bdaa7b3c2c7012..32f79e9e06334b74325d9c50471085de40aed256 100644 (file)
@@ -1,44 +1,45 @@
-<?php namespace Tests\Uploads;
+<?php
 
+namespace Tests\Uploads;
+
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Image;
-use BookStack\Entities\Models\Page;
 use BookStack\Uploads\ImageService;
 use Illuminate\Support\Str;
 use Tests\TestCase;
 
 class ImageTest extends TestCase
 {
-
     use UsesImages;
 
     public function test_image_upload()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
         $imgDetails = $this->uploadGalleryImage($page);
         $relPath = $imgDetails['path'];
 
-        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: '. public_path($relPath));
+        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: ' . public_path($relPath));
 
         $this->deleteImage($relPath);
 
         $this->assertDatabaseHas('images', [
-            'url' => $this->baseUrl . $relPath,
-            'type' => 'gallery',
+            'url'         => $this->baseUrl . $relPath,
+            'type'        => 'gallery',
             'uploaded_to' => $page->id,
-            'path' => $relPath,
-            'created_by' => $admin->id,
-            'updated_by' => $admin->id,
-            'name' => $imgDetails['name'],
+            'path'        => $relPath,
+            'created_by'  => $admin->id,
+            'updated_by'  => $admin->id,
+            'name'        => $imgDetails['name'],
         ]);
     }
 
     public function test_image_display_thumbnail_generation_does_not_increase_image_size()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
@@ -47,7 +48,7 @@ class ImageTest extends TestCase
         $imgDetails = $this->uploadGalleryImage($page, 'compressed.png');
         $relPath = $imgDetails['path'];
 
-        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: '. public_path($relPath));
+        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: ' . public_path($relPath));
         $displayImage = $imgDetails['response']->thumbs->display;
 
         $displayImageRelPath = implode('/', array_slice(explode('/', $displayImage), 3));
@@ -60,6 +61,19 @@ class ImageTest extends TestCase
         $this->assertEquals($originalFileSize, $displayFileSize, 'Display thumbnail generation should not increase image size');
     }
 
+    public function test_image_display_thumbnail_generation_for_apng_images_uses_original_file()
+    {
+        $page = Page::query()->first();
+        $admin = $this->getAdmin();
+        $this->actingAs($admin);
+
+        $imgDetails = $this->uploadGalleryImage($page, 'animated.png');
+        $this->deleteImage($imgDetails['path']);
+
+        $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
+        $this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
+    }
+
     public function test_image_edit()
     {
         $editor = $this->getEditor();
@@ -77,7 +91,7 @@ class ImageTest extends TestCase
 
         $this->assertDatabaseHas('images', [
             'type' => 'gallery',
-            'name' => $newName
+            'name' => $newName,
         ]);
     }
 
@@ -108,14 +122,14 @@ class ImageTest extends TestCase
 
     public function test_image_usage()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
         $imgDetails = $this->uploadGalleryImage($page);
 
         $image = Image::query()->first();
-        $page->html = '<img src="'.$image->url.'">';
+        $page->html = '<img src="' . $image->url . '">';
         $page->save();
 
         $usage = $this->get('/images/edit/' . $image->id . '?delete=true');
@@ -128,7 +142,7 @@ class ImageTest extends TestCase
 
     public function test_php_files_cannot_be_uploaded()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
@@ -144,13 +158,13 @@ class ImageTest extends TestCase
 
         $this->assertDatabaseMissing('images', [
             'type' => 'gallery',
-            'name' => $fileName
+            'name' => $fileName,
         ]);
     }
 
     public function test_php_like_files_cannot_be_uploaded()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
@@ -194,15 +208,15 @@ class ImageTest extends TestCase
     {
         $this->asEditor();
         $badNames = [
-            "bad-char-#-image.png",
-            "bad-char-?-image.png",
-            "?#.png",
-            "?.png",
-            "#.png",
+            'bad-char-#-image.png',
+            'bad-char-?-image.png',
+            '?#.png',
+            '?.png',
+            '#.png',
         ];
         foreach ($badNames as $name) {
             $galleryFile = $this->getTestImage($name);
-            $page = Page::first();
+            $page = Page::query()->first();
             $badPath = $this->getTestImagePath('gallery', $name);
             $this->deleteImage($badPath);
 
@@ -227,26 +241,56 @@ class ImageTest extends TestCase
         config()->set('filesystems.images', 'local_secure');
         $this->asEditor();
         $galleryFile = $this->getTestImage('my-secure-test-upload.png');
-        $page = Page::first();
-        $expectedPath = storage_path('uploads/images/gallery/' . Date('Y-m') . '/my-secure-test-upload.png');
+        $page = Page::query()->first();
+        $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-test-upload.png');
 
         $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
         $upload->assertStatus(200);
 
-        $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: '. $expectedPath);
+        $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath);
 
         if (file_exists($expectedPath)) {
             unlink($expectedPath);
         }
     }
 
+    public function test_secure_image_paths_traversal_causes_500()
+    {
+        config()->set('filesystems.images', 'local_secure');
+        $this->asEditor();
+
+        $resp = $this->get('/uploads/images/../../logs/laravel.log');
+        $resp->assertStatus(500);
+    }
+
+    public function test_secure_image_paths_traversal_on_non_secure_images_causes_404()
+    {
+        config()->set('filesystems.images', 'local');
+        $this->asEditor();
+
+        $resp = $this->get('/uploads/images/../../logs/laravel.log');
+        $resp->assertStatus(404);
+    }
+
+    public function test_secure_image_paths_dont_serve_non_images()
+    {
+        config()->set('filesystems.images', 'local_secure');
+        $this->asEditor();
+
+        $testFilePath = storage_path('/uploads/images/testing.txt');
+        file_put_contents($testFilePath, 'hello from test_secure_image_paths_dont_serve_non_images');
+
+        $resp = $this->get('/uploads/images/testing.txt');
+        $resp->assertStatus(404);
+    }
+
     public function test_secure_images_included_in_exports()
     {
         config()->set('filesystems.images', 'local_secure');
         $this->asEditor();
         $galleryFile = $this->getTestImage('my-secure-test-upload.png');
-        $page = Page::first();
-        $expectedPath = storage_path('uploads/images/gallery/' . Date('Y-m') . '/my-secure-test-upload.png');
+        $page = Page::query()->first();
+        $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-test-upload.png');
 
         $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
         $imageUrl = json_decode($upload->getContent(), true)['url'];
@@ -268,12 +312,12 @@ class ImageTest extends TestCase
         config()->set('filesystems.images', 'local_secure');
         $this->asAdmin();
         $galleryFile = $this->getTestImage('my-system-test-upload.png');
-        $expectedPath = public_path('uploads/images/system/' . Date('Y-m') . '/my-system-test-upload.png');
+        $expectedPath = public_path('uploads/images/system/' . date('Y-m') . '/my-system-test-upload.png');
 
         $upload = $this->call('POST', '/settings', [], [], ['app_logo' => $galleryFile], []);
         $upload->assertRedirect('/settings');
 
-        $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: '. $expectedPath);
+        $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath);
 
         if (file_exists($expectedPath)) {
             unlink($expectedPath);
@@ -282,7 +326,7 @@ class ImageTest extends TestCase
 
     public function test_image_delete()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $imageName = 'first-image.png';
         $relPath = $this->getTestImagePath('gallery', $imageName);
@@ -291,12 +335,12 @@ class ImageTest extends TestCase
         $this->uploadImage($imageName, $page->id);
         $image = Image::first();
 
-        $delete = $this->delete( '/images/' . $image->id);
+        $delete = $this->delete('/images/' . $image->id);
         $delete->assertStatus(200);
 
         $this->assertDatabaseMissing('images', [
-            'url' => $this->baseUrl . $relPath,
-            'type' => 'gallery'
+            'url'  => $this->baseUrl . $relPath,
+            'type' => 'gallery',
         ]);
 
         $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected');
@@ -304,7 +348,7 @@ class ImageTest extends TestCase
 
     public function test_image_delete_does_not_delete_similar_images()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $imageName = 'first-image.png';
 
@@ -319,7 +363,7 @@ class ImageTest extends TestCase
         $folder = public_path(dirname($relPath));
         $imageCount = count(glob($folder . '/*'));
 
-        $delete = $this->delete( '/images/' . $image->id);
+        $delete = $this->delete('/images/' . $image->id);
         $delete->assertStatus(200);
 
         $newCount = count(glob($folder . '/*'));
@@ -346,9 +390,9 @@ class ImageTest extends TestCase
         $this->call('PUT', '/settings/users/' . $editor->id, [], [], ['profile_image' => $file], []);
 
         $this->assertDatabaseHas('images', [
-            'type' => 'user',
+            'type'        => 'user',
             'uploaded_to' => $editor->id,
-            'created_by' => $admin->id,
+            'created_by'  => $admin->id,
         ]);
     }
 
@@ -361,7 +405,7 @@ class ImageTest extends TestCase
         $this->call('PUT', '/settings/users/' . $editor->id, [], [], ['profile_image' => $file], []);
 
         $profileImages = Image::where('type', '=', 'user')->where('created_by', '=', $editor->id)->get();
-        $this->assertTrue($profileImages->count() === 1, "Found profile images does not match upload count");
+        $this->assertTrue($profileImages->count() === 1, 'Found profile images does not match upload count');
 
         $imagePath = public_path($profileImages->first()->path);
         $this->assertTrue(file_exists($imagePath));
@@ -370,12 +414,12 @@ class ImageTest extends TestCase
         $userDelete->assertStatus(302);
 
         $this->assertDatabaseMissing('images', [
-            'type' => 'user',
-            'created_by' => $editor->id
+            'type'       => 'user',
+            'created_by' => $editor->id,
         ]);
         $this->assertDatabaseMissing('images', [
-            'type' => 'user',
-            'uploaded_to' => $editor->id
+            'type'        => 'user',
+            'uploaded_to' => $editor->id,
         ]);
 
         $this->assertFalse(file_exists($imagePath));
@@ -383,7 +427,7 @@ class ImageTest extends TestCase
 
     public function test_deleted_unused_images()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
@@ -397,9 +441,9 @@ class ImageTest extends TestCase
 
         $pageRepo = app(PageRepo::class);
         $pageRepo->update($page, [
-            'name' => $page->name,
-            'html' => $page->html . "<img src=\"{$image->url}\">",
-            'summary' => ''
+            'name'    => $page->name,
+            'html'    => $page->html . "<img src=\"{$image->url}\">",
+            'summary' => '',
         ]);
 
         // Ensure no images are reported as deletable
@@ -409,9 +453,9 @@ class ImageTest extends TestCase
 
         // Save a revision of our page without the image;
         $pageRepo->update($page, [
-            'name' => $page->name,
-            'html' => "<p>Hello</p>",
-            'summary' => ''
+            'name'    => $page->name,
+            'html'    => '<p>Hello</p>',
+            'summary' => '',
         ]);
 
         // Ensure revision images are picked up okay
@@ -435,5 +479,4 @@ class ImageTest extends TestCase
 
         $this->deleteImage($relPath);
     }
-
 }
index 24c253802f23dd72ccc2d4252ba79334b2a105c2..b55572248a8bc1668da8b34cce264b696c5fa9b7 100644 (file)
@@ -1,7 +1,10 @@
-<?php namespace Tests\Uploads;
+<?php
+
+namespace Tests\Uploads;
 
 use BookStack\Entities\Models\Page;
 use Illuminate\Http\UploadedFile;
+use stdClass;
 
 trait UsesImages
 {
@@ -29,11 +32,12 @@ trait UsesImages
         $data = file_get_contents($base64FilePath);
         $decoded = base64_decode($data);
         file_put_contents($imagePath, $decoded);
+
         return new UploadedFile($imagePath, $imageFileName, 'image/png', null, true);
     }
 
     /**
-     * Get a test image that can be uploaded
+     * Get a test image that can be uploaded.
      */
     protected function getTestImage(string $fileName, ?string $testDataFileName = null): UploadedFile
     {
@@ -42,6 +46,7 @@ trait UsesImages
 
     /**
      * Get the raw file data for the test image.
+     *
      * @return false|string
      */
     protected function getTestImageContent()
@@ -54,19 +59,22 @@ trait UsesImages
      */
     protected function getTestImagePath(string $type, string $fileName): string
     {
-        return '/uploads/images/' . $type . '/' . Date('Y-m') . '/' . $fileName;
+        return '/uploads/images/' . $type . '/' . date('Y-m') . '/' . $fileName;
     }
 
     /**
      * Uploads an image with the given name.
+     *
      * @param $name
-     * @param int $uploadedTo
+     * @param int    $uploadedTo
      * @param string $contentType
+     *
      * @return \Illuminate\Foundation\Testing\TestResponse
      */
     protected function uploadImage($name, $uploadedTo = 0, $contentType = 'image/png', ?string $testDataFileName = null)
     {
         $file = $this->getTestImage($name, $testDataFileName);
+
         return $this->withHeader('Content-Type', $contentType)
             ->call('POST', '/images/gallery', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
     }
@@ -75,8 +83,10 @@ trait UsesImages
      * Upload a new gallery image.
      * Returns the image name.
      * Can provide a page to relate the image to.
+     *
      * @param Page|null $page
-     * @return array
+     *
+     * @return array{name: string, path: string, page: Page, response: stdClass}
      */
     protected function uploadGalleryImage(Page $page = null, ?string $testDataFileName = null)
     {
@@ -90,10 +100,11 @@ trait UsesImages
 
         $upload = $this->uploadImage($imageName, $page->id, 'image/png', $testDataFileName);
         $upload->assertStatus(200);
+
         return [
-            'name' => $imageName,
-            'path' => $relPath,
-            'page' => $page,
+            'name'     => $imageName,
+            'path'     => $relPath,
+            'page'     => $page,
             'response' => json_decode($upload->getContent()),
         ];
     }
@@ -108,5 +119,4 @@ trait UsesImages
             unlink($path);
         }
     }
-
-}
\ No newline at end of file
+}
index df686dd77df953423a103d8caa57313539dd832e..d3404b72ef14a11e21af96408d190b2d88f544f3 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\User;
+<?php
+
+namespace Tests\User;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Api\ApiToken;
@@ -7,9 +9,8 @@ use Tests\TestCase;
 
 class UserApiTokenTest extends TestCase
 {
-
     protected $testTokenData = [
-        'name' => 'My test API token',
+        'name'       => 'My test API token',
         'expires_at' => '2050-04-01',
     ];
 
@@ -51,8 +52,8 @@ class UserApiTokenTest extends TestCase
         $token = ApiToken::query()->latest()->first();
         $resp->assertRedirect($editor->getEditUrl('/api-tokens/' . $token->id));
         $this->assertDatabaseHas('api_tokens', [
-            'user_id' => $editor->id,
-            'name' => $this->testTokenData['name'],
+            'user_id'    => $editor->id,
+            'name'       => $this->testTokenData['name'],
             'expires_at' => $this->testTokenData['expires_at'],
         ]);
 
@@ -81,7 +82,7 @@ class UserApiTokenTest extends TestCase
         $under = Carbon::now()->addYears(99);
         $this->assertTrue(
             ($token->expires_at < $over && $token->expires_at > $under),
-            "Token expiry set at 100 years in future"
+            'Token expiry set at 100 years in future'
         );
     }
 
@@ -117,7 +118,7 @@ class UserApiTokenTest extends TestCase
         $this->asAdmin()->post($editor->getEditUrl('/create-api-token'), $this->testTokenData);
         $token = ApiToken::query()->latest()->first();
         $updateData = [
-            'name' => 'My updated token',
+            'name'       => 'My updated token',
             'expires_at' => '2011-01-01',
         ];
 
@@ -136,7 +137,7 @@ class UserApiTokenTest extends TestCase
         $token = ApiToken::query()->latest()->first();
 
         $resp = $this->put($editor->getEditUrl('/api-tokens/' . $token->id), [
-            'name' => 'My updated token',
+            'name'       => 'My updated token',
             'expires_at' => '',
         ]);
         $token->refresh();
@@ -145,7 +146,7 @@ class UserApiTokenTest extends TestCase
         $under = Carbon::now()->addYears(99);
         $this->assertTrue(
             ($token->expires_at < $over && $token->expires_at > $under),
-            "Token expiry set at 100 years in future"
+            'Token expiry set at 100 years in future'
         );
     }
 
@@ -160,7 +161,7 @@ class UserApiTokenTest extends TestCase
         $resp = $this->get($tokenUrl . '/delete');
         $resp->assertSeeText('Delete Token');
         $resp->assertSeeText($token->name);
-        $resp->assertElementExists('form[action="'.$tokenUrl.'"]');
+        $resp->assertElementExists('form[action="' . $tokenUrl . '"]');
 
         $resp = $this->delete($tokenUrl);
         $resp->assertRedirect($editor->getEditUrl('#api_tokens'));
@@ -185,5 +186,4 @@ class UserApiTokenTest extends TestCase
         $resp->assertRedirect($viewer->getEditUrl('#api_tokens'));
         $this->assertDatabaseMissing('api_tokens', ['id' => $token->id]);
     }
-
-}
\ No newline at end of file
+}
index d99d61401e19e0385a07a6ec16e90d8e25a87bc7..2fbbee7e20725b09a90d011df4c2a616da88ce71 100644 (file)
-<?php namespace Tests\User;
+<?php
+
+namespace Tests\User;
 
 use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\UserInviteService;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use Mockery\MockInterface;
+use RuntimeException;
 use Tests\TestCase;
 
 class UserManagementTest extends TestCase
 {
+    public function test_user_creation()
+    {
+        /** @var User $user */
+        $user = User::factory()->make();
+        $adminRole = Role::getRole('admin');
+
+        $resp = $this->asAdmin()->get('/settings/users');
+        $resp->assertElementContains('a[href="' . url('/settings/users/create') . '"]', 'Add New User');
+
+        $this->get('/settings/users/create')
+            ->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,
+            'roles[' . $adminRole->id . ']' => 'true',
+        ]);
+        $resp->assertRedirect('/settings/users');
+
+        $resp = $this->get('/settings/users');
+        $resp->assertSee($user->name);
+
+        $this->assertDatabaseHas('users', $user->only('name', 'email'));
+
+        $user->refresh();
+        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
+    }
+
+    public function test_user_updating()
+    {
+        $user = $this->getNormalUser();
+        $password = $user->password;
+
+        $resp = $this->asAdmin()->get('/settings/users/' . $user->id);
+        $resp->assertSee($user->email);
+
+        $this->put($user->getEditUrl(), [
+            'name' => 'Barry Scott',
+        ])->assertRedirect('/settings/users');
+
+        $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password]);
+        $this->assertDatabaseMissing('users', ['name' => $user->name]);
+
+        $user->refresh();
+        $this->assertStringStartsWith(Str::slug($user->name), $user->slug);
+    }
+
+    public function test_user_password_update()
+    {
+        $user = $this->getNormalUser();
+        $userProfilePage = '/settings/users/' . $user->id;
+
+        $this->asAdmin()->get($userProfilePage);
+        $this->put($userProfilePage, [
+            'password' => 'newpassword',
+        ])->assertRedirect($userProfilePage);
+
+        $this->get($userProfilePage)->assertSee('Password confirmation required');
+
+        $this->put($userProfilePage, [
+            'password'         => 'newpassword',
+            'password-confirm' => 'newpassword',
+        ])->assertRedirect('/settings/users');
+
+        $userPassword = User::query()->find($user->id)->password;
+        $this->assertTrue(Hash::check('newpassword', $userPassword));
+    }
+
+    public function test_user_cannot_be_deleted_if_last_admin()
+    {
+        $adminRole = Role::getRole('admin');
+
+        // Delete all but one admin user if there are more than one
+        $adminUsers = $adminRole->users;
+        if (count($adminUsers) > 1) {
+            /** @var User $user */
+            foreach ($adminUsers->splice(1) as $user) {
+                $user->delete();
+            }
+        }
+
+        // Ensure we currently only have 1 admin user
+        $this->assertEquals(1, $adminRole->users()->count());
+        /** @var User $user */
+        $user = $adminRole->users->first();
+
+        $resp = $this->asAdmin()->delete('/settings/users/' . $user->id);
+        $resp->assertRedirect('/settings/users/' . $user->id);
+
+        $resp = $this->get('/settings/users/' . $user->id);
+        $resp->assertSee('You cannot delete the only admin');
+
+        $this->assertDatabaseHas('users', ['id' => $user->id]);
+    }
 
     public function test_delete()
     {
         $editor = $this->getEditor();
         $resp = $this->asAdmin()->delete("settings/users/{$editor->id}");
-        $resp->assertRedirect("/settings/users");
+        $resp->assertRedirect('/settings/users');
         $resp = $this->followRedirects($resp);
 
-        $resp->assertSee("User successfully removed");
+        $resp->assertSee('User successfully removed');
         $this->assertActivityExists(ActivityType::USER_DELETE);
 
         $this->assertDatabaseMissing('users', ['id' => $editor->id]);
@@ -25,20 +129,109 @@ class UserManagementTest extends TestCase
     {
         $editor = $this->getEditor();
         $resp = $this->asAdmin()->get("settings/users/{$editor->id}/delete");
-        $resp->assertSee("Migrate Ownership");
-        $resp->assertSee("new_owner_id");
+        $resp->assertSee('Migrate Ownership');
+        $resp->assertSee('new_owner_id');
+    }
+
+    public function test_migrate_option_hidden_if_user_cannot_manage_users()
+    {
+        $editor = $this->getEditor();
+
+        $resp = $this->asEditor()->get("settings/users/{$editor->id}/delete");
+        $resp->assertDontSee('Migrate Ownership');
+        $resp->assertDontSee('new_owner_id');
+
+        $this->giveUserPermissions($editor, ['users-manage']);
+
+        $resp = $this->asEditor()->get("settings/users/{$editor->id}/delete");
+        $resp->assertSee('Migrate Ownership');
+        $resp->assertSee('new_owner_id');
     }
 
     public function test_delete_with_new_owner_id_changes_ownership()
     {
         $page = Page::query()->first();
         $owner = $page->ownedBy;
-        $newOwner = User::query()->where('id', '!=' , $owner->id)->first();
+        $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,
+            'id'       => $page->id,
             'owned_by' => $newOwner->id,
         ]);
     }
-}
\ No newline at end of file
+
+    public function test_guest_profile_shows_limited_form()
+    {
+        $guest = User::getDefault();
+        $resp = $this->asAdmin()->get('/settings/users/' . $guest->id);
+        $resp->assertSee('Guest');
+        $resp->assertElementNotExists('#password');
+    }
+
+    public function test_guest_profile_cannot_be_deleted()
+    {
+        $guestUser = User::getDefault();
+        $resp = $this->asAdmin()->get('/settings/users/' . $guestUser->id . '/delete');
+        $resp->assertSee('Delete User');
+        $resp->assertSee('Guest');
+        $resp->assertElementContains('form[action$="/settings/users/' . $guestUser->id . '"] button', 'Confirm');
+
+        $resp = $this->delete('/settings/users/' . $guestUser->id);
+        $resp->assertRedirect('/settings/users/' . $guestUser->id);
+        $resp = $this->followRedirects($resp);
+        $resp->assertSee('cannot delete the guest user');
+    }
+
+    public function test_user_create_language_reflects_default_system_locale()
+    {
+        $langs = ['en', 'fr', 'hr'];
+        foreach ($langs as $lang) {
+            config()->set('app.locale', $lang);
+            $resp = $this->asAdmin()->get('/settings/users/create');
+            $resp->assertElementExists('select[name="setting[language]"] option[value="' . $lang . '"][selected]');
+        }
+    }
+
+    public function test_user_creation_is_not_performed_if_the_invitation_sending_fails()
+    {
+        /** @var User $user */
+        $user = User::factory()->make();
+        $adminRole = Role::getRole('admin');
+
+        // Simulate an invitation sending failure
+        $this->mock(UserInviteService::class, function (MockInterface $mock) {
+            $mock->shouldReceive('sendInvitation')->once()->andThrow(RuntimeException::class);
+        });
+
+        $this->asAdmin()->post('/settings/users/create', [
+            'name'                          => $user->name,
+            'email'                         => $user->email,
+            'send_invite'                   => 'true',
+            'roles[' . $adminRole->id . ']' => 'true',
+        ]);
+
+        // Since the invitation failed, the user should not exist in the database
+        $this->assertDatabaseMissing('users', $user->only('name', 'email'));
+    }
+
+    public function test_user_create_activity_is_not_persisted_if_the_invitation_sending_fails()
+    {
+        /** @var User $user */
+        $user = User::factory()->make();
+        $adminRole = Role::getRole('admin');
+
+        $this->mock(UserInviteService::class, function (MockInterface $mock) {
+            $mock->shouldReceive('sendInvitation')->once()->andThrow(RuntimeException::class);
+        });
+
+        $this->asAdmin()->post('/settings/users/create', [
+            'name'                          => $user->name,
+            'email'                         => $user->email,
+            'send_invite'                   => 'true',
+            'roles[' . $adminRole->id . ']' => 'true',
+        ]);
+
+        $this->assertDatabaseMissing('activities', ['type' => 'USER_CREATE']);
+    }
+}
index 49c49188b2451f0b74cf017210f0b5baafd23f80..b39c2c47c84bee8145b0340baade7a183d5bdac9 100644 (file)
@@ -1,28 +1,30 @@
-<?php namespace Tests\User;
+<?php
 
+namespace Tests\User;
+
+use BookStack\Entities\Models\Bookshelf;
 use Tests\TestCase;
 
 class UserPreferencesTest extends TestCase
 {
-
     public function test_update_sort_preference()
     {
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id.'/change-sort/books', [
-            'sort' => 'created_at',
-            'order' => 'desc'
+        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/books', [
+            'sort'  => 'created_at',
+            'order' => 'desc',
         ]);
         $updateRequest->assertStatus(302);
 
         $this->assertDatabaseHas('settings', [
             'setting_key' => 'user:' . $editor->id . ':books_sort',
-            'value' => 'created_at'
+            'value'       => 'created_at',
         ]);
         $this->assertDatabaseHas('settings', [
             'setting_key' => 'user:' . $editor->id . ':books_sort_order',
-            'value' => 'desc'
+            'value'       => 'desc',
         ]);
         $this->assertEquals('created_at', setting()->getForCurrentUser('books_sort'));
         $this->assertEquals('desc', setting()->getForCurrentUser('books_sort_order'));
@@ -33,9 +35,9 @@ class UserPreferencesTest extends TestCase
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id.'/change-sort/bookshelves', [
-            'sort' => 'cat',
-            'order' => 'dog'
+        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/bookshelves', [
+            'sort'  => 'cat',
+            'order' => 'dog',
         ]);
         $updateRequest->assertStatus(302);
 
@@ -48,9 +50,9 @@ class UserPreferencesTest extends TestCase
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id.'/change-sort/dogs', [
-            'sort' => 'name',
-            'order' => 'asc'
+        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/dogs', [
+            'sort'  => 'name',
+            'order' => 'asc',
         ]);
         $updateRequest->assertStatus(500);
 
@@ -63,16 +65,16 @@ class UserPreferencesTest extends TestCase
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id.'/update-expansion-preference/home-details', ['expand' => 'true']);
+        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/update-expansion-preference/home-details', ['expand' => 'true']);
         $updateRequest->assertStatus(204);
 
         $this->assertDatabaseHas('settings', [
             'setting_key' => 'user:' . $editor->id . ':section_expansion#home-details',
-            'value' => 'true'
+            'value'       => 'true',
         ]);
         $this->assertEquals(true, setting()->getForCurrentUser('section_expansion#home-details'));
 
-        $invalidKeyRequest = $this->patch('/settings/users/' . $editor->id.'/update-expansion-preference/my-home-details', ['expand' => 'true']);
+        $invalidKeyRequest = $this->patch('/settings/users/' . $editor->id . '/update-expansion-preference/my-home-details', ['expand' => 'true']);
         $invalidKeyRequest->assertStatus(500);
     }
 
@@ -105,4 +107,44 @@ class UserPreferencesTest extends TestCase
         $home = $this->get('/login');
         $home->assertElementExists('.dark-mode');
     }
-}
\ No newline at end of file
+
+    public function test_books_view_type_preferences_when_list()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'books_view_type', 'list');
+
+        $this->actingAs($editor)->get('/books')
+            ->assertElementNotExists('.featured-image-container')
+            ->assertElementExists('.content-wrap .entity-list-item');
+    }
+
+    public function test_books_view_type_preferences_when_grid()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'books_view_type', 'grid');
+
+        $this->actingAs($editor)->get('/books')
+            ->assertElementExists('.featured-image-container');
+    }
+
+    public function test_shelf_view_type_change()
+    {
+        $editor = $this->getEditor();
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+        setting()->putUser($editor, 'bookshelf_view_type', 'list');
+
+        $this->actingAs($editor)->get($shelf->getUrl())
+            ->assertElementNotExists('.featured-image-container')
+            ->assertElementExists('.content-wrap .entity-list-item')
+            ->assertSee('Grid View');
+
+        $req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']);
+        $req->assertRedirect($shelf->getUrl());
+
+        $this->actingAs($editor)->get($shelf->getUrl())
+            ->assertElementExists('.featured-image-container')
+            ->assertElementNotExists('.content-wrap .entity-list-item')
+            ->assertSee('List View');
+    }
+}
index a5db83c48e78816ed4af15402f8c5185209272b5..8693689759dac65884d355db94977aae67c52524 100644 (file)
@@ -1,16 +1,20 @@
-<?php namespace Tests\User;
+<?php
+
+namespace Tests\User;
 
 use Activity;
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
-use BookStack\Entities\Models\Bookshelf;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class UserProfileTest extends BrowserKitTest
+class UserProfileTest extends TestCase
 {
+    /**
+     * @var User
+     */
     protected $user;
 
-    public function setUp(): void
+    protected function setUp(): void
     {
         parent::setUp();
         $this->user = User::all()->last();
@@ -19,127 +23,83 @@ class UserProfileTest extends BrowserKitTest
     public function test_profile_page_shows_name()
     {
         $this->asAdmin()
-            ->visit('/user/' . $this->user->slug)
-            ->see($this->user->name);
+            ->get('/user/' . $this->user->slug)
+            ->assertSee($this->user->name);
     }
 
     public function test_profile_page_shows_recent_entities()
     {
         $content = $this->createEntityChainBelongingToUser($this->user, $this->user);
 
-        $this->asAdmin()
-            ->visit('/user/' . $this->user->slug)
-            // Check the recently created page is shown
-            ->see($content['page']->name)
-            // Check the recently created chapter is shown
-            ->see($content['chapter']->name)
-            // Check the recently created book is shown
-            ->see($content['book']->name);
+        $resp = $this->asAdmin()->get('/user/' . $this->user->slug);
+        // Check the recently created page is shown
+        $resp->assertSee($content['page']->name);
+        // Check the recently created chapter is shown
+        $resp->assertSee($content['chapter']->name);
+        // Check the recently created book is shown
+        $resp->assertSee($content['book']->name);
     }
 
     public function test_profile_page_shows_created_content_counts()
     {
-        $newUser = $this->getNewBlankUser();
+        $newUser = User::factory()->create();
 
-        $this->asAdmin()->visit('/user/' . $newUser->slug)
-            ->see($newUser->name)
-            ->seeInElement('#content-counts', '0 Books')
-            ->seeInElement('#content-counts', '0 Chapters')
-            ->seeInElement('#content-counts', '0 Pages');
+        $this->asAdmin()->get('/user/' . $newUser->slug)
+            ->assertSee($newUser->name)
+            ->assertElementContains('#content-counts', '0 Books')
+            ->assertElementContains('#content-counts', '0 Chapters')
+            ->assertElementContains('#content-counts', '0 Pages');
 
         $this->createEntityChainBelongingToUser($newUser, $newUser);
 
-        $this->asAdmin()->visit('/user/' . $newUser->slug)
-            ->see($newUser->name)
-            ->seeInElement('#content-counts', '1 Book')
-            ->seeInElement('#content-counts', '1 Chapter')
-            ->seeInElement('#content-counts', '1 Page');
+        $this->asAdmin()->get('/user/' . $newUser->slug)
+            ->assertSee($newUser->name)
+            ->assertElementContains('#content-counts', '1 Book')
+            ->assertElementContains('#content-counts', '1 Chapter')
+            ->assertElementContains('#content-counts', '1 Page');
     }
 
     public function test_profile_page_shows_recent_activity()
     {
-        $newUser = $this->getNewBlankUser();
+        $newUser = User::factory()->create();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
-        Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
-        Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
+        Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
+        Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
 
-        $this->asAdmin()->visit('/user/' . $newUser->slug)
-            ->seeInElement('#recent-user-activity', 'updated book')
-            ->seeInElement('#recent-user-activity', 'created page')
-            ->seeInElement('#recent-user-activity', $entities['page']->name);
+        $this->asAdmin()->get('/user/' . $newUser->slug)
+            ->assertElementContains('#recent-user-activity', 'updated book')
+            ->assertElementContains('#recent-user-activity', 'created page')
+            ->assertElementContains('#recent-user-activity', $entities['page']->name);
     }
 
-    public function test_clicking_user_name_in_activity_leads_to_profile_page()
+    public function test_user_activity_has_link_leading_to_profile()
     {
-        $newUser = $this->getNewBlankUser();
+        $newUser = User::factory()->create();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
-        Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
-        Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
-
-        $this->asAdmin()->visit('/')->clickInElement('#recent-activity', $newUser->name)
-            ->seePageIs('/user/' . $newUser->slug)
-            ->see($newUser->name);
-    }
-
-    public function test_guest_profile_shows_limited_form()
-    {
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click('Guest')
-            ->dontSeeElement('#password');
-    }
-
-    public function test_guest_profile_cannot_be_deleted()
-    {
-        $guestUser = User::getDefault();
-        $this->asAdmin()->visit('/settings/users/' . $guestUser->id . '/delete')
-            ->see('Delete User')->see('Guest')
-            ->press('Confirm')
-            ->seePageIs('/settings/users/' . $guestUser->id)
-            ->see('cannot delete the guest user');
-    }
+        Activity::add(ActivityType::BOOK_UPDATE, $entities['book']);
+        Activity::add(ActivityType::PAGE_CREATE, $entities['page']);
 
-    public function test_books_view_is_list()
-    {
-        $editor = $this->getEditor();
-        setting()->putUser($editor, 'books_view_type', 'list');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->pageNotHasElement('.featured-image-container')
-            ->pageHasElement('.content-wrap .entity-list-item');
+        $linkSelector = '#recent-activity a[href$="/user/' . $newUser->slug . '"]';
+        $this->asAdmin()->get('/')
+            ->assertElementContains($linkSelector, $newUser->name);
     }
 
-    public function test_books_view_is_grid()
+    public function test_profile_has_search_links_in_created_entity_lists()
     {
-        $editor = $this->getEditor();
-        setting()->putUser($editor, 'books_view_type', 'grid');
-
-        $this->actingAs($editor)
-            ->visit('/books')
-            ->pageHasElement('.featured-image-container');
+        $user = $this->getEditor();
+        $resp = $this->actingAs($this->getAdmin())->get('/user/' . $user->slug);
+
+        $expectedLinks = [
+            '/search?term=%7Bcreated_by%3A' . $user->slug . '%7D+%7Btype%3Apage%7D',
+            '/search?term=%7Bcreated_by%3A' . $user->slug . '%7D+%7Btype%3Achapter%7D',
+            '/search?term=%7Bcreated_by%3A' . $user->slug . '%7D+%7Btype%3Abook%7D',
+            '/search?term=%7Bcreated_by%3A' . $user->slug . '%7D+%7Btype%3Abookshelf%7D',
+        ];
+
+        foreach ($expectedLinks as $link) {
+            $resp->assertElementContains('[href$="' . $link . '"]', 'View All');
+        }
     }
-
-    public function test_shelf_view_type_change()
-    {
-        $editor = $this->getEditor();
-        $shelf = Bookshelf::query()->first();
-        setting()->putUser($editor, 'bookshelf_view_type', 'list');
-
-        $this->actingAs($editor)->visit($shelf->getUrl())
-            ->pageNotHasElement('.featured-image-container')
-            ->pageHasElement('.content-wrap .entity-list-item')
-            ->see('Grid View');
-
-        $req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']);
-        $req->assertRedirectedTo($shelf->getUrl());
-
-        $this->actingAs($editor)->visit($shelf->getUrl())
-            ->pageHasElement('.featured-image-container')
-            ->pageNotHasElement('.content-wrap .entity-list-item')
-            ->see('List View');
-    }
-
 }
diff --git a/tests/User/UserSearchTest.php b/tests/User/UserSearchTest.php
new file mode 100644 (file)
index 0000000..243af11
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace Tests\User;
+
+use BookStack\Auth\User;
+use Tests\TestCase;
+
+class UserSearchTest extends TestCase
+{
+    public function test_select_search_matches_by_name()
+    {
+        $viewer = $this->getViewer();
+        $admin = $this->getAdmin();
+        $resp = $this->actingAs($admin)->get('/search/users/select?search=' . urlencode($viewer->name));
+
+        $resp->assertOk();
+        $resp->assertSee($viewer->name);
+        $resp->assertDontSee($admin->name);
+    }
+
+    public function test_select_search_shows_first_by_name_without_search()
+    {
+        /** @var User $firstUser */
+        $firstUser = User::query()->orderBy('name', 'desc')->first();
+        $resp = $this->asAdmin()->get('/search/users/select');
+
+        $resp->assertOk();
+        $resp->assertSee($firstUser->name);
+    }
+
+    public function test_select_search_does_not_match_by_email()
+    {
+        $viewer = $this->getViewer();
+        $editor = $this->getEditor();
+        $resp = $this->actingAs($editor)->get('/search/users/select?search=' . urlencode($viewer->email));
+
+        $resp->assertDontSee($viewer->name);
+    }
+
+    public function test_select_requires_right_permission()
+    {
+        $permissions = ['users-manage', 'restrictions-manage-own', 'restrictions-manage-all'];
+        $user = $this->getViewer();
+
+        foreach ($permissions as $permission) {
+            $resp = $this->actingAs($user)->get('/search/users/select?search=a');
+            $this->assertPermissionError($resp);
+
+            $this->giveUserPermissions($user, [$permission]);
+            $resp = $this->actingAs($user)->get('/search/users/select?search=a');
+            $resp->assertOk();
+            $user->roles()->delete();
+            $user->clearPermissionCache();
+        }
+    }
+
+    public function test_select_requires_logged_in_user()
+    {
+        $this->setSettings(['app-public' => true]);
+        $defaultUser = User::getDefault();
+        $this->giveUserPermissions($defaultUser, ['users-manage']);
+
+        $resp = $this->get('/search/users/select?search=a');
+        $this->assertPermissionError($resp);
+    }
+}
diff --git a/tests/test-data/animated.png b/tests/test-data/animated.png
new file mode 100644 (file)
index 0000000..1b35084
Binary files /dev/null and b/tests/test-data/animated.png differ
diff --git a/version b/version
index 92d9faea7781a62c65cccb97e9dd1292d1184271..8f6af9beed9605ee6117c937ed602cbefb4f2ff2 100644 (file)
--- a/version
+++ b/version
@@ -1 +1 @@
-v0.32-dev
+v21.11-dev