]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'openid' of https://github.com/jasperweyne/BookStack into jasperweyne...
authorDan Brown <redacted>
Wed, 6 Oct 2021 12:17:30 +0000 (13:17 +0100)
committerDan Brown <redacted>
Wed, 6 Oct 2021 12:18:21 +0000 (13:18 +0100)
1050 files changed:
.env.example
.env.example.complete
.github/FUNDING.yml [new file with mode: 0644]
.github/ISSUE_TEMPLATE/api_request.md [new file with mode: 0644]
.github/ISSUE_TEMPLATE/config.yml [new file with mode: 0644]
.github/translators.txt
.github/workflows/phpunit.yml
.github/workflows/test-migrations.yml [new file with mode: 0644]
app/Actions/Activity.php
app/Actions/ActivityService.php
app/Actions/ActivityType.php [new file with mode: 0644]
app/Actions/Comment.php
app/Actions/CommentRepo.php
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/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/ExternalAuthService.php
app/Auth/Access/ExternalBaseUserProvider.php
app/Auth/Access/Guards/ExternalBaseSessionGuard.php
app/Auth/Access/Guards/LdapSessionGuard.php
app/Auth/Access/Guards/Saml2SessionGuard.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/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
app/Config/auth.php
app/Config/broadcasting.php
app/Config/cache.php
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/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/ClearRevisions.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/Chapter.php [deleted file]
app/Entities/EntityProvider.php
app/Entities/Managers/TrashCan.php [deleted file]
app/Entities/Models/Book.php [moved from app/Entities/Book.php with 72% similarity]
app/Entities/Models/BookChild.php [moved from app/Entities/BookChild.php with 55% similarity]
app/Entities/Models/Bookshelf.php [moved from app/Entities/Bookshelf.php with 76% similarity]
app/Entities/Models/Chapter.php [new file with mode: 0644]
app/Entities/Models/Deletion.php [new file with mode: 0644]
app/Entities/Models/Entity.php [moved from app/Entities/Entity.php with 60% similarity]
app/Entities/Models/HasCoverImage.php [moved from app/Entities/HasCoverImage.php with 90% similarity]
app/Entities/Models/Page.php [new file with mode: 0644]
app/Entities/Models/PageRevision.php [moved from app/Entities/PageRevision.php with 74% similarity]
app/Entities/Models/SearchTerm.php [moved from app/Entities/SearchTerm.php with 76% similarity]
app/Entities/Page.php [deleted file]
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/SlugGenerator.php [deleted file]
app/Entities/Tools/BookContents.php [moved from app/Entities/Managers/BookContents.php with 83% similarity]
app/Entities/Tools/ExportFormatter.php [moved from app/Entities/ExportService.php with 75% similarity]
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 [new file with mode: 0644]
app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php [new file with mode: 0644]
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 [moved from app/Entities/Managers/PageContent.php with 53% similarity]
app/Entities/Tools/PageEditActivity.php [moved from app/Entities/Managers/PageEditActivity.php with 65% similarity]
app/Entities/Tools/PermissionsUpdater.php [new file with mode: 0644]
app/Entities/Tools/SearchIndex.php [new file with mode: 0644]
app/Entities/Tools/SearchOptions.php [moved from app/Entities/SearchOptions.php with 93% similarity]
app/Entities/Tools/SearchRunner.php [moved from app/Entities/SearchService.php with 63% similarity]
app/Entities/Tools/ShelfContext.php [moved from app/Entities/Managers/EntityContext.php with 56% similarity]
app/Entities/Tools/SiblingFetcher.php [new file with mode: 0644]
app/Entities/Tools/SlugGenerator.php [new file with mode: 0644]
app/Entities/Tools/TrashCan.php [new file with mode: 0644]
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/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
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/Facades/Activity.php
app/Facades/Permissions.php
app/Facades/Setting.php [deleted file]
app/Facades/Theme.php [moved from app/Facades/Images.php with 59% similarity]
app/Facades/Views.php [deleted file]
app/Http/Controllers/Api/ApiController.php
app/Http/Controllers/Api/ApiDocsController.php
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 [new file with mode: 0644]
app/Http/Controllers/Api/PageExportApiController.php [new file with mode: 0644]
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/AuditLogController.php [new file with mode: 0644]
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/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 [new file with mode: 0644]
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 [new file with mode: 0644]
app/Http/Controllers/RoleController.php [moved from app/Http/Controllers/PermissionController.php with 66% similarity]
app/Http/Controllers/SearchController.php
app/Http/Controllers/SettingController.php
app/Http/Controllers/StatusController.php [new file with mode: 0644]
app/Http/Controllers/TagController.php
app/Http/Controllers/UserApiTokenController.php
app/Http/Controllers/UserController.php
app/Http/Controllers/UserProfileController.php [new file with mode: 0644]
app/Http/Controllers/UserSearchController.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/GlobalViewData.php [deleted file]
app/Http/Middleware/Localization.php
app/Http/Middleware/PermissionMiddleware.php [deleted file]
app/Http/Middleware/RedirectIfAuthenticated.php
app/Http/Middleware/RunThemeActions.php [new file with mode: 0644]
app/Http/Middleware/ThrottleApiRequests.php
app/Http/Middleware/TrustProxies.php
app/Http/Middleware/VerifyCsrfToken.php
app/Http/Request.php
app/Interfaces/Favouritable.php [new file with mode: 0644]
app/Interfaces/Loggable.php [new file with mode: 0644]
app/Interfaces/Sluggable.php [new file with mode: 0644]
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/Ownable.php [deleted file]
app/Providers/AppServiceProvider.php
app/Providers/AuthServiceProvider.php
app/Providers/CustomFacadeProvider.php
app/Providers/CustomValidationServiceProvider.php [new file with mode: 0644]
app/Providers/PaginationServiceProvider.php
app/Providers/RouteServiceProvider.php
app/Providers/ThemeServiceProvider.php [new file with mode: 0644]
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 [new file with mode: 0644]
app/Theming/ThemeService.php [new file with mode: 0644]
app/Traits/HasCreatorAndUpdater.php [new file with mode: 0644]
app/Traits/HasOwner.php [new file with mode: 0644]
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/UploadService.php [deleted file]
app/Uploads/UserAvatars.php [new file with mode: 0644]
app/Util/CspService.php [new file with mode: 0644]
app/Util/HtmlContentFilter.php [new file with mode: 0644]
app/Util/HtmlNonceApplicator.php [new file with mode: 0644]
app/helpers.php
artisan
bootstrap/init.php [deleted file]
composer.json
composer.lock
database/factories/ModelFactory.php
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_111754_drop_joint_permissions_id.php [new file with mode: 0644]
database/migrations/2020_08_04_131052_remove_role_name_field.php [new file with mode: 0644]
database/migrations/2020_09_19_094251_add_activity_indexes.php [new file with mode: 0644]
database/migrations/2020_09_27_210059_add_entity_soft_deletes.php [new file with mode: 0644]
database/migrations/2020_09_27_210528_create_deletions_table.php [new file with mode: 0644]
database/migrations/2020_11_07_232321_simplify_activities_table.php [new file with mode: 0644]
database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php [new file with mode: 0644]
database/migrations/2021_01_30_225441_add_settings_type_column.php [new file with mode: 0644]
database/migrations/2021_03_08_215138_add_user_slug.php [new file with mode: 0644]
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/seeds/DatabaseSeeder.php
database/seeds/DummyContentSeeder.php
database/seeds/LargeContentSeeder.php
dev/api/requests/pages-create.json [new file with mode: 0644]
dev/api/requests/pages-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 [new file with mode: 0644]
dev/api/responses/pages-list.json [new file with mode: 0644]
dev/api/responses/pages-read.json [new file with mode: 0644]
dev/api/responses/pages-update.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/entrypoint.app.sh
dev/docker/entrypoint.node.sh
dev/docker/init.db/01.sql [new file with mode: 0644]
dev/docs/components.md
dev/docs/logical-theme-system.md [new file with mode: 0644]
dev/docs/visual-theme-system.md [new file with mode: 0644]
docker-compose.yml
package-lock.json
package.json
phpcs.xml [deleted file]
phpunit.xml
public/.htaccess
public/index.php
readme.md
resources/icons/star-circle.svg
resources/icons/star-outline.svg [new file with mode: 0644]
resources/icons/tag.svg
resources/js/components/ajax-delete-row.js [new file with mode: 0644]
resources/js/components/ajax-form.js [new file with mode: 0644]
resources/js/components/attachments-list.js [new file with mode: 0644]
resources/js/components/attachments.js [new file with mode: 0644]
resources/js/components/book-sort.js
resources/js/components/breadcrumb-listing.js [deleted file]
resources/js/components/dropdown-search.js [new file with mode: 0644]
resources/js/components/dropdown.js
resources/js/components/dropzone.js [new file with mode: 0644]
resources/js/components/entity-selector.js
resources/js/components/event-emit-select.js [new file with mode: 0644]
resources/js/components/header-mobile-toggle.js
resources/js/components/image-manager.js [new file with mode: 0644]
resources/js/components/index.js
resources/js/components/markdown-editor.js
resources/js/components/page-comments.js
resources/js/components/page-display.js
resources/js/components/page-editor.js [new file with mode: 0644]
resources/js/components/sortable-list.js
resources/js/components/submit-on-change.js [new file with mode: 0644]
resources/js/components/tabs.js [new file with mode: 0644]
resources/js/components/tri-layout.js
resources/js/components/user-select.js [new file with mode: 0644]
resources/js/components/wysiwyg-editor.js
resources/js/index.js
resources/js/services/code.js
resources/js/services/dom.js
resources/js/services/drawio.js
resources/js/services/events.js
resources/js/services/http.js
resources/js/services/translations.js
resources/js/vues/attachment-manager.js [deleted file]
resources/js/vues/components/dropzone.js [deleted file]
resources/js/vues/image-manager.js [deleted file]
resources/js/vues/page-editor.js [deleted file]
resources/js/vues/vues.js [deleted file]
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/passwords.php
resources/lang/ar/settings.php
resources/lang/ar/validation.php
resources/lang/bg/activities.php [new file with mode: 0644]
resources/lang/bg/auth.php [new file with mode: 0644]
resources/lang/bg/common.php [new file with mode: 0644]
resources/lang/bg/components.php [new file with mode: 0644]
resources/lang/bg/entities.php [new file with mode: 0644]
resources/lang/bg/errors.php [new file with mode: 0644]
resources/lang/bg/pagination.php [new file with mode: 0644]
resources/lang/bg/passwords.php [new file with mode: 0644]
resources/lang/bg/settings.php [new file with mode: 0644]
resources/lang/bg/validation.php [new file with mode: 0644]
resources/lang/bs/activities.php [new file with mode: 0644]
resources/lang/bs/auth.php [new file with mode: 0644]
resources/lang/bs/common.php [new file with mode: 0644]
resources/lang/bs/components.php [new file with mode: 0644]
resources/lang/bs/entities.php [new file with mode: 0644]
resources/lang/bs/errors.php [new file with mode: 0644]
resources/lang/bs/pagination.php [new file with mode: 0644]
resources/lang/bs/passwords.php [new file with mode: 0644]
resources/lang/bs/settings.php [new file with mode: 0644]
resources/lang/bs/validation.php [new file with mode: 0644]
resources/lang/ca/activities.php [new file with mode: 0644]
resources/lang/ca/auth.php [new file with mode: 0644]
resources/lang/ca/common.php [new file with mode: 0644]
resources/lang/ca/components.php [new file with mode: 0644]
resources/lang/ca/entities.php [new file with mode: 0644]
resources/lang/ca/errors.php [new file with mode: 0644]
resources/lang/ca/pagination.php [new file with mode: 0644]
resources/lang/ca/passwords.php [new file with mode: 0644]
resources/lang/ca/settings.php [new file with mode: 0755]
resources/lang/ca/validation.php [new file with mode: 0644]
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/pagination.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/components.php
resources/lang/da/entities.php
resources/lang/da/errors.php
resources/lang/da/pagination.php
resources/lang/da/passwords.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/components.php
resources/lang/de/entities.php
resources/lang/de/errors.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/components.php
resources/lang/de_informal/entities.php
resources/lang/de_informal/errors.php
resources/lang/de_informal/passwords.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/components.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/components.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/components.php
resources/lang/es_AR/entities.php
resources/lang/es_AR/errors.php
resources/lang/es_AR/passwords.php
resources/lang/es_AR/settings.php
resources/lang/es_AR/validation.php
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/components.php
resources/lang/he/entities.php
resources/lang/he/errors.php
resources/lang/he/passwords.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/components.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 [new file with mode: 0644]
resources/lang/id/auth.php [new file with mode: 0644]
resources/lang/id/common.php [new file with mode: 0644]
resources/lang/id/components.php [new file with mode: 0644]
resources/lang/id/entities.php [new file with mode: 0644]
resources/lang/id/errors.php [new file with mode: 0644]
resources/lang/id/pagination.php [new file with mode: 0644]
resources/lang/id/passwords.php [new file with mode: 0644]
resources/lang/id/settings.php [new file with mode: 0644]
resources/lang/id/validation.php [new file with mode: 0644]
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/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/components.php
resources/lang/ko/entities.php
resources/lang/ko/errors.php
resources/lang/ko/passwords.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 [new file with mode: 0644]
resources/lang/lv/auth.php [new file with mode: 0644]
resources/lang/lv/common.php [new file with mode: 0644]
resources/lang/lv/components.php [new file with mode: 0644]
resources/lang/lv/entities.php [new file with mode: 0644]
resources/lang/lv/errors.php [new file with mode: 0644]
resources/lang/lv/pagination.php [new file with mode: 0644]
resources/lang/lv/passwords.php [new file with mode: 0644]
resources/lang/lv/settings.php [new file with mode: 0644]
resources/lang/lv/validation.php [new file with mode: 0644]
resources/lang/nb/activities.php [new file with mode: 0644]
resources/lang/nb/auth.php [new file with mode: 0644]
resources/lang/nb/common.php [new file with mode: 0644]
resources/lang/nb/components.php [new file with mode: 0644]
resources/lang/nb/entities.php [new file with mode: 0644]
resources/lang/nb/errors.php [new file with mode: 0644]
resources/lang/nb/pagination.php [new file with mode: 0644]
resources/lang/nb/passwords.php [new file with mode: 0644]
resources/lang/nb/settings.php [new file with mode: 0644]
resources/lang/nb/validation.php [new file with mode: 0644]
resources/lang/nl/activities.php
resources/lang/nl/auth.php
resources/lang/nl/common.php
resources/lang/nl/components.php
resources/lang/nl/entities.php
resources/lang/nl/errors.php
resources/lang/nl/passwords.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/components.php
resources/lang/pl/entities.php
resources/lang/pl/errors.php
resources/lang/pl/passwords.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/components.php
resources/lang/pt/entities.php
resources/lang/pt/errors.php
resources/lang/pt/pagination.php
resources/lang/pt/passwords.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/components.php
resources/lang/pt_BR/entities.php
resources/lang/pt_BR/errors.php
resources/lang/pt_BR/passwords.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/components.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/components.php
resources/lang/sk/entities.php
resources/lang/sk/errors.php
resources/lang/sk/passwords.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/components.php
resources/lang/sl/entities.php
resources/lang/sl/errors.php
resources/lang/sl/pagination.php
resources/lang/sl/passwords.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/components.php
resources/lang/sv/entities.php
resources/lang/sv/errors.php
resources/lang/sv/passwords.php
resources/lang/sv/settings.php
resources/lang/sv/validation.php
resources/lang/th/common.php
resources/lang/th/components.php
resources/lang/th/entities.php
resources/lang/th/errors.php
resources/lang/th/settings.php
resources/lang/th/validation.php
resources/lang/tr/activities.php
resources/lang/tr/auth.php
resources/lang/tr/common.php
resources/lang/tr/components.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/components.php
resources/lang/uk/entities.php
resources/lang/uk/errors.php
resources/lang/uk/passwords.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/components.php
resources/lang/vi/entities.php
resources/lang/vi/errors.php
resources/lang/vi/passwords.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/components.php
resources/lang/zh_CN/entities.php
resources/lang/zh_CN/errors.php
resources/lang/zh_CN/passwords.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/components.php
resources/lang/zh_TW/entities.php
resources/lang/zh_TW/errors.php
resources/lang/zh_TW/passwords.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/_colors.scss
resources/sass/_components.scss
resources/sass/_footer.scss [new file with mode: 0644]
resources/sass/_forms.scss
resources/sass/_header.scss
resources/sass/_html.scss
resources/sass/_layout.scss
resources/sass/_lists.scss
resources/sass/_tables.scss
resources/sass/_text.scss
resources/sass/_tinymce.scss
resources/sass/_variables.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 [new file with mode: 0644]
resources/views/attachments/manager-edit-form.blade.php [new file with mode: 0644]
resources/views/attachments/manager-link-form.blade.php [new file with mode: 0644]
resources/views/attachments/manager-list.blade.php [new file with mode: 0644]
resources/views/attachments/manager.blade.php [new file with mode: 0644]
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-openid.blade.php [moved from resources/views/auth/forms/login/openid.blade.php with 97% similarity]
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/create.blade.php
resources/views/books/delete.blade.php
resources/views/books/edit.blade.php
resources/views/books/export.blade.php
resources/views/books/grid-item.blade.php [deleted file]
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 80% 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 93% similarity]
resources/views/books/permissions.blade.php
resources/views/books/show.blade.php
resources/views/books/sort.blade.php
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 66% 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 63% similarity]
resources/views/chapters/permissions.blade.php
resources/views/chapters/show.blade.php
resources/views/comments/comment.blade.php
resources/views/comments/comments.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 78% 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 [new file with mode: 0644]
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 63% similarity]
resources/views/common/footer.blade.php [new file with mode: 0644]
resources/views/common/header.blade.php
resources/views/common/home-book.blade.php [deleted file]
resources/views/common/home-shelves.blade.php [deleted file]
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/entity-selector.blade.php [deleted file]
resources/views/components/image-manager.blade.php [deleted file]
resources/views/components/tag-list.blade.php [deleted file]
resources/views/entities/book-tree.blade.php [moved from resources/views/partials/book-tree.blade.php with 58% similarity]
resources/views/entities/breadcrumb-listing.blade.php [new file with mode: 0644]
resources/views/entities/breadcrumbs.blade.php [moved from resources/views/partials/breadcrumbs.blade.php with 92% similarity]
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 [new file with mode: 0644]
resources/views/entities/favourite-action.blade.php [new file with mode: 0644]
resources/views/entities/grid-item.blade.php [new file with mode: 0644]
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 100% similarity]
resources/views/entities/list-item.blade.php [moved from resources/views/partials/entity-list-item.blade.php with 64% similarity]
resources/views/entities/list.blade.php [moved from resources/views/partials/entity-list.blade.php with 65% similarity]
resources/views/entities/meta.blade.php [new file with mode: 0644]
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 [new file with mode: 0644]
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 93% 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 [moved from resources/views/components/tag-manager.blade.php with 84% similarity]
resources/views/entities/view-toggle.blade.php [moved from resources/views/partials/view-toggle.blade.php with 84% similarity]
resources/views/errors/404.blade.php
resources/views/errors/500.blade.php
resources/views/errors/503.blade.php
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 [new file with mode: 0644]
resources/views/form/entity-permissions.blade.php
resources/views/form/image-picker.blade.php [moved from resources/views/components/image-picker.blade.php with 100% similarity]
resources/views/form/password.blade.php
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 [new file with mode: 0644]
resources/views/form/user-select.blade.php [new file with mode: 0644]
resources/views/home/books.blade.php [new file with mode: 0644]
resources/views/home/default.blade.php [moved from resources/views/common/home.blade.php with 56% similarity]
resources/views/home/parts/expand-toggle.blade.php [moved from resources/views/components/expand-toggle.blade.php with 74% similarity]
resources/views/home/parts/sidebar.blade.php [new file with mode: 0644]
resources/views/home/shelves.blade.php [new file with mode: 0644]
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 68% similarity]
resources/views/layouts/export.blade.php [new file with mode: 0644]
resources/views/layouts/simple.blade.php [moved from resources/views/simple-layout.blade.php with 68% similarity]
resources/views/layouts/tri.blade.php [new file with mode: 0644]
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/attachment-manager.blade.php [deleted file]
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/form.blade.php [deleted file]
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 95% similarity]
resources/views/pages/parts/editor-toolbox.blade.php [moved from resources/views/pages/editor-toolbox.blade.php with 81% similarity]
resources/views/pages/parts/form.blade.php [new file with mode: 0644]
resources/views/pages/parts/image-manager-form.blade.php [new file with mode: 0644]
resources/views/pages/parts/image-manager-list.blade.php [new file with mode: 0644]
resources/views/pages/parts/image-manager.blade.php [new file with mode: 0644]
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 77% similarity]
resources/views/pages/parts/page-display.blade.php [moved from resources/views/pages/page-display.blade.php with 71% 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 [new file with mode: 0644]
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/pages/wysiwyg-editor.blade.php [deleted file]
resources/views/partials/breadcrumb-listing.blade.php [deleted file]
resources/views/partials/custom-head-content.blade.php [deleted file]
resources/views/partials/custom-head.blade.php [deleted file]
resources/views/partials/entity-export-meta.blade.php [deleted file]
resources/views/partials/entity-meta.blade.php [deleted file]
resources/views/readme.md [new file with mode: 0644]
resources/views/search/all.blade.php
resources/views/search/book.blade.php [deleted file]
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 [new file with mode: 0644]
resources/views/settings/index.blade.php
resources/views/settings/maintenance.blade.php
resources/views/settings/parts/footer-links.blade.php [new file with mode: 0644]
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 66% 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 [new file with mode: 0644]
resources/views/settings/recycle-bin/destroy.blade.php [new file with mode: 0644]
resources/views/settings/recycle-bin/index.blade.php [new file with mode: 0644]
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 [new file with mode: 0644]
resources/views/settings/recycle-bin/restore.blade.php [new file with mode: 0644]
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
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/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/grid-item.blade.php [deleted file]
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 82% 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/tri-layout.blade.php [deleted file]
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 94% similarity]
resources/views/users/profile.blade.php
routes/api.php
routes/web.php
server.php
tests/ActivityTrackingTest.php [deleted file]
tests/Api/ApiAuthTest.php
tests/Api/ApiConfigTest.php
tests/Api/ApiDocsTest.php
tests/Api/ApiListingTest.php
tests/Api/BooksApiTest.php
tests/Api/ChaptersApiTest.php
tests/Api/PagesApiTest.php [new file with mode: 0644]
tests/Api/ShelvesApiTest.php
tests/Api/TestsApi.php
tests/AuditLogTest.php [new file with mode: 0644]
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/Saml2Test.php
tests/Auth/SocialAuthTest.php
tests/Auth/UserInviteTest.php
tests/BrowserKitTest.php [deleted file]
tests/Commands/AddAdminCommandTest.php [new file with mode: 0644]
tests/Commands/ClearActivityCommandTest.php [new file with mode: 0644]
tests/Commands/ClearRevisionsCommandTest.php [new file with mode: 0644]
tests/Commands/ClearViewsCommandTest.php [new file with mode: 0644]
tests/Commands/CopyShelfPermissionsCommandTest.php [new file with mode: 0644]
tests/Commands/RegenerateCommentContentCommandTest.php [new file with mode: 0644]
tests/Commands/RegeneratePermissionsCommandTest.php [new file with mode: 0644]
tests/Commands/ResetMfaCommandTest.php [new file with mode: 0644]
tests/Commands/UpdateUrlCommandTest.php [new file with mode: 0644]
tests/CommandsTest.php [deleted file]
tests/CreatesApplication.php
tests/Entity/BookShelfTest.php
tests/Entity/BookTest.php [new file with mode: 0644]
tests/Entity/ChapterTest.php [new file with mode: 0644]
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 [new file with mode: 0644]
tests/Entity/SearchOptionsTest.php
tests/Entity/SortTest.php
tests/Entity/TagTest.php
tests/ErrorTest.php
tests/FavouriteTest.php [new file with mode: 0644]
tests/HomepageTest.php
tests/LanguageTest.php
tests/OpenGraphTest.php [new file with mode: 0644]
tests/Permissions/EntityOwnerChangeTest.php [new file with mode: 0644]
tests/Permissions/EntityPermissionsTest.php [new file with mode: 0644]
tests/Permissions/ExportPermissionsTest.php [new file with mode: 0644]
tests/Permissions/RestrictionsTest.php [deleted file]
tests/Permissions/RolesTest.php
tests/PublicActionTest.php
tests/RecycleBinTest.php [new file with mode: 0644]
tests/SecurityHeaderTest.php [new file with mode: 0644]
tests/Settings/CustomHeadContentTest.php [new file with mode: 0644]
tests/Settings/FooterLinksTest.php [new file with mode: 0644]
tests/SharedTestHelpers.php
tests/StatusTest.php [new file with mode: 0644]
tests/TestCase.php
tests/TestEmailTest.php
tests/TestResponse.php
tests/ThemeTest.php
tests/Unit/ConfigTest.php
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 [new file with mode: 0644]
tests/User/UserPreferencesTest.php
tests/User/UserProfileTest.php
tests/test-data/bad-php.base64 [new file with mode: 0644]
tests/test-data/bad-phtml-png.base64 [new file with mode: 0644]
tests/test-data/bad-phtml.base64 [new file with mode: 0644]
tests/test-data/bad.php [deleted file]
tests/test-data/bad.phtml [deleted file]
tests/test-data/bad.phtml.png [deleted file]
version
webpack.config.js [deleted file]

index f5e81277cedbdc90bb6a896b4c1476f16154c95a..a0a1b72e6836bcab495a34c0f8633ea295edb644 100644 (file)
@@ -1,14 +1,24 @@
+# This file, when named as ".env" in the root of your BookStack install
+# folder, is used for the core configuration of the application.
+# By default this file contains the most common required options but
+# a full list of options can be found in the '.env.example.complete' file.
+
+# NOTE: If any of your values contain a space or a hash you will need to
+# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
+
 # Application key
 # Used for encryption where needed.
 # Run `php artisan key:generate` to generate a valid key.
 APP_KEY=SomeRandomString
 
 # Application URL
-# Remove the hash below and set a URL if using BookStack behind
-# a proxy, if using a third-party authentication option.
 # This must be the root URL that you want to host BookStack on.
-# All URL's in BookStack will be generated using this value.
-#APP_URL=https://example.com
+# All URLs in BookStack will be generated using this value
+# to ensure URLs generated are consistent and secure.
+# If you change this in the future you may need to run a command
+# to update stored URLs in the database. Command example:
+# php artisan bookstack:update-url https://old.example.com https://new.example.com
+APP_URL=https://example.com
 
 # Database details
 DB_HOST=localhost
@@ -20,16 +30,15 @@ DB_PASSWORD=database_user_password
 # Can be 'smtp' or 'sendmail'
 MAIL_DRIVER=smtp
 
-# Mail sender options
-MAIL_FROM_NAME=BookStack
+# Mail sender details
+MAIL_FROM_NAME="BookStack"
 
 # SMTP mail options
+# These settings can be checked using the "Send a Test Email"
+# feature found in the "Settings > Maintenance" area of the system.
 MAIL_HOST=localhost
 MAIL_PORT=1025
 MAIL_USERNAME=null
 MAIL_PASSWORD=null
 MAIL_ENCRYPTION=null
-
-
-# A full list of options can be found in the '.env.example.complete' file.
\ No newline at end of file
index b211ad939cacb96acfcbf87c6469dd5a95b3cb1e..5a586d1d1426b4b914506d156a12e4f8e9a9e6a1 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
@@ -51,7 +59,7 @@ DB_USERNAME=database_username
 DB_PASSWORD=database_user_password
 
 # Mail system to use
-# Can be 'smtp', 'mail' or 'sendmail'
+# Can be 'smtp' or 'sendmail'
 MAIL_DRIVER=smtp
 
 # Mail sending options
@@ -195,10 +203,12 @@ LDAP_DN=false
 LDAP_PASS=false
 LDAP_USER_FILTER=false
 LDAP_VERSION=false
+LDAP_START_TLS=false
 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
 
@@ -221,6 +231,7 @@ SAML2_IDP_x509=null
 SAML2_ONELOGIN_OVERRIDES=null
 SAML2_DUMP_USER_DETAILS=false
 SAML2_AUTOLOAD_METADATA=false
+SAML2_IDP_AUTHNCONTEXT=true
 
 # SAML group sync configuration
 # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
@@ -246,23 +257,36 @@ DISABLE_EXTERNAL_SERVICES=false
 # Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
 AVATAR_URL=
 
-# Enable draw.io integration
+# Enable diagrams.net integration
 # Can simply be true/false to enable/disable the integration.
-# Alternatively, It can be URL to the draw.io instance you want to use.
+# Alternatively, It can be URL to the diagrams.net instance you want to use.
 # For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
 DRAWIO=true
 
 # Default item listing view
-# Used for public visitors and user's without a preference
-# Can be 'list' or 'grid'
+# Used for public visitors and user's without a preference.
+# Can be 'list' or 'grid'.
 APP_VIEWS_BOOKS=list
 APP_VIEWS_BOOKSHELVES=grid
+APP_VIEWS_BOOKSHELF=grid
+
+# Use dark mode by default
+# Will be overriden by any user/session preference.
+APP_DEFAULT_DARK_MODE=false
 
 # Page revision limit
 # Number of page revisions to keep in the system before deleting old revisions.
 # If set to 'false' a limit will not be enforced.
 REVISION_LIMIT=50
 
+# Recycle Bin Lifetime
+# The number of days that content will remain in the recycle bin before
+# being considered for auto-removal. It is not a guarantee that content will
+# be removed after this time.
+# Set to 0 for no recycle bin functionality.
+# Set to -1 for unlimited recycle bin lifetime.
+RECYCLE_BIN_LIFETIME=30
+
 # Allow <script> tags in page content
 # Note, if set to 'true' the page editor may still escape scripts.
 ALLOW_CONTENT_SCRIPTS=false
@@ -273,9 +297,29 @@ 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"
+# Setting this option will also auto-adjust cookies to be SameSite=None.
+ALLOWED_IFRAME_HOSTS=null
+
 # The default and maximum item-counts for listing API requests.
 API_DEFAULT_ITEM_COUNT=100
 API_MAX_ITEM_COUNT=500
 
 # The number of API requests that can be made per minute by a single user.
-API_REQUESTS_PER_MIN=180
\ No newline at end of file
+API_REQUESTS_PER_MIN=180
+
+# Enable the logging of failed email+password logins with the given message.
+# The default log channel below uses the php 'error_log' function which commonly
+# results in messages being output to the webserver error logs.
+# The message can contain a %u parameter which will be replaced with the login
+# user identifier (Username or email).
+LOG_FAILED_LOGIN_MESSAGE=false
+LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644 (file)
index 0000000..01b8471
--- /dev/null
@@ -0,0 +1,3 @@
+# These are supported funding model platforms
+
+github: [ssddanbrown]
diff --git a/.github/ISSUE_TEMPLATE/api_request.md b/.github/ISSUE_TEMPLATE/api_request.md
new file mode 100644 (file)
index 0000000..dc050ef
--- /dev/null
@@ -0,0 +1,17 @@
+---
+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/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644 (file)
index 0000000..d0ac0c6
--- /dev/null
@@ -0,0 +1,9 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Discord chat support
+    url: https://discord.gg/ztkBqR2
+    about: Realtime support / chat with the community and the team.
+
+  - name: Debugging & Common Issues
+    url: https://www.bookstackapp.com/docs/admin/debugging/
+    about: Find details on how to debug issues and view common issues with thier resolutions.
index 098751fcd85030fd3d063bfda55081e548f90045..78dccde42e7c5acfdce9ca13a5fd547d3af1eb29 100644 (file)
@@ -49,6 +49,12 @@ Name :: Languages
 @jzoy :: Simplified Chinese
 @ististudio :: Korean
 @leomartinez :: Spanish Argentina
+@geins :: German
+@Ereza :: Catalan
+@benediktvolke :: German
+@Baptistou :: French
+@arcoai :: Spanish
+@Jokuna :: Korean
 cipi1965 :: Italian
 Mykola Ronik (Mantikor) :: Ukrainian
 furkanoyk :: Turkish
@@ -61,7 +67,7 @@ Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
 aekramer :: Dutch
 JachuPL :: Polish
 milesteg :: Hungarian
-Beenbag :: German
+Beenbag :: German; German Informal
 Lett3rs :: Danish
 Julian (julian.henneberg) :: German; German Informal
 3GNWn :: Danish
@@ -98,3 +104,89 @@ Thinkverse (thinkverse) :: Swedish
 alef (toishoki) :: Turkish
 Robbert Feunekes (Muukuro) :: Dutch
 seohyeon.joo :: Korean
+Orenda (OREDNA) :: Bulgarian
+Marek Pavelka (marapavelka) :: Czech
+Venkinovec :: Czech
+Tommy Ku (tommyku) :: Chinese Traditional; Japanese
+Michał Bielejewski  (bielej) :: Polish
+jozefrebjak :: Slovak
+Ikhwan Koo (Ikhwan.Koo) :: Korean
+Whay (remkovdhoef) :: Dutch
+jc7115 :: Chinese Traditional
+주서현 (seohyeon.joo) :: Korean
+ReadySystems :: Arabic
+HFinch :: German; German Informal
+brechtgijsens :: Dutch
+Lowkey (v587ygq) :: Chinese Simplified
+sdl-blue :: German Informal
+sqlik :: Polish
+Roy van Schaijk (royvanschaijk) :: Dutch
+Simsimpicpic :: French
+Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
+tatsuya.info :: Japanese
+fadiapp :: Arabic
+Jakub Bouček (jakubboucek) :: Czech
+Marco (cdrfun) :: German
+10935336 :: Chinese Simplified
+孟繁阳 (FanyangMeng) :: Chinese Simplified
+Andrej Močan (andrejm) :: Slovenian
+gilane9_ :: Arabic
+Raed alnahdi (raednahdi) :: Arabic
+Xiphoseer :: German
+MerlinSVK (merlinsvk) :: Slovak
+Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
+MatthieuParis :: French
+Douradinho :: Portuguese, Brazilian
+Gaku Yaguchi (tama11) :: Japanese
+johnroyer :: Chinese Traditional
+jackaaa :: Chinese Traditional
+Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
+Jeff Huang (s8321414) :: Chinese Traditional
+Luís Tiago Favas (starkyller) :: Portuguese
+semirte :: Bosnian
+aarchijs :: Latvian
+Martins Pilsetnieks (pilsetnieks) :: Latvian
+Yonatan Magier (yonatanmgr) :: Hebrew
+FastHogi :: German Informal; German
+Ole Anders (Swoy) :: Norwegian Bokmal
+Atlochowski (atlochowski) :: Polish
+Simon (DefaultSimon) :: Slovenian
+Reinis Mednis (Mednis) :: Latvian
+toisho (toishoki) :: Turkish
+nikservik :: Ukrainian; Russian; Polish
+HenrijsS :: Latvian
+Pascal R-B (pborgner) :: German
+Boris (Ginfred) :: Russian
+Jonas Anker Rasmussen (jonasanker) :: Danish
+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
index 7646f06879ab81802f774d19e2610476f02f265c..f12de643601bb73d47c635b9c89f0be78286bcf2 100644 (file)
@@ -2,24 +2,27 @@ name: phpunit
 
 on:
   push:
-    branches:
-      - master
-      - release
+    branches-ignore:
+      - l10n_master
   pull_request:
-    branches:
-      - '*'
-      - '*/*'
-      - '!l10n_master'
+    branches-ignore:
+      - l10n_master
 
 jobs:
   build:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-20.04
     strategy:
       matrix:
-        php: [7.2, 7.4]
+        php: ['7.3', '7.4', '8.0']
     steps:
     - uses: actions/checkout@v1
 
+    - name: Setup PHP
+      uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
+      with:
+        php-version: ${{ matrix.php }}
+        extensions: gd, mbstring, json, curl, xml, mysql, ldap
+
     - name: Get Composer Cache Directory
       id: composer-cache
       run: |
@@ -38,7 +41,7 @@ jobs:
     - name: Setup Database
       run: |
         mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
-        mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
+        mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
         mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
         mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
 
diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml
new file mode 100644 (file)
index 0000000..c24a019
--- /dev/null
@@ -0,0 +1,61 @@
+name: test-migrations
+
+on:
+  push:
+    branches-ignore:
+      - l10n_master
+  pull_request:
+    branches-ignore:
+      - l10n_master
+
+jobs:
+  build:
+    runs-on: ubuntu-20.04
+    strategy:
+      matrix:
+        php: ['7.3', '7.4', '8.0']
+    steps:
+      - uses: actions/checkout@v1
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@b7d1d9c9a92d8d8463ce36d7f60da34d461724f8
+        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: Start MySQL
+        run: |
+          sudo /etc/init.d/mysql start
+
+      - name: Create database & user
+        run: |
+          mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
+          mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
+          mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
+          mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
+
+      - name: Install composer dependencies
+        run: composer install --prefer-dist --no-interaction --ansi
+
+      - name: Start migration test
+        run: |
+          php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
+
+      - name: Start migration:rollback test
+        run: |
+          php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
+
+      - name: Start migration rerun test
+        run: |
+          php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
index 035a9cc750ef16618ee81b8f48e0981c2442f8da..6a8a9bcd07bbd66c673bf919f2213112f13be90e 100644 (file)
@@ -3,49 +3,59 @@
 namespace BookStack\Actions;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+use Illuminate\Support\Str;
 
 /**
- * @property string $key
- * @property User $user
+ * @property string $type
+ * @property User   $user
  * @property Entity $entity
- * @property string $extra
+ * @property string $detail
  * @property string $entity_type
- * @property int $entity_id
- * @property int $user_id
- * @property int $book_id
+ * @property int    $entity_id
+ * @property int    $user_id
  */
 class Activity extends Model
 {
-
     /**
      * Get the entity for this activity.
      */
-    public function entity()
+    public function entity(): MorphTo
     {
         if ($this->entity_type === '') {
             $this->entity_type = null;
         }
+
         return $this->morphTo('entity');
     }
 
     /**
      * Get the user this activity relates to.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function user()
+    public function user(): BelongsTo
     {
         return $this->belongsTo(User::class);
     }
 
     /**
-     * Returns text from the language files, Looks up by using the
-     * activity key.
+     * Returns text from the language files, Looks up by using the activity key.
+     */
+    public function getText(): string
+    {
+        return trans('activities.' . $this->type);
+    }
+
+    /**
+     * Check if this activity is intended to be for an entity.
      */
-    public function getText()
+    public function isForEntity(): bool
     {
-        return trans('activities.' . $this->key);
+        return Str::startsWith($this->type, [
+            'page_', 'chapter_', 'book_', 'bookshelf_',
+        ]);
     }
 
     /**
@@ -53,6 +63,6 @@ class Activity extends Model
      */
     public function isSimilarTo(Activity $activityB): bool
     {
-        return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
+        return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
     }
 }
index 9b69cbb1747662240d4c4055f8647f58a17bdb9b..bc7a6b6b7c3353f39384ae73d88eaca284d5ed41 100644 (file)
@@ -1,57 +1,66 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\User;
-use BookStack\Entities\Entity;
-use Illuminate\Support\Collection;
+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 $user;
     protected $permissionService;
 
-    /**
-     * ActivityService constructor.
-     */
     public function __construct(Activity $activity, PermissionService $permissionService)
     {
         $this->activity = $activity;
         $this->permissionService = $permissionService;
-        $this->user = user();
     }
 
     /**
-     * Add activity data to database.
+     * Add activity data to database for an entity.
      */
-    public function add(Entity $entity, string $activityKey, ?int $bookId = null)
+    public function addForEntity(Entity $entity, string $type)
     {
-        $activity = $this->newActivityForUser($activityKey, $bookId);
+        $activity = $this->newActivityForUser($type);
         $entity->activity()->save($activity);
-        $this->setNotification($activityKey);
+        $this->setNotification($type);
     }
 
     /**
-     * Adds a activity history with a message, without binding to a entity.
+     * Add a generic activity event to the database.
+     *
+     * @param string|Loggable $detail
      */
-    public function addMessage(string $activityKey, string $message, ?int $bookId = null)
+    public function add(string $type, $detail = '')
     {
-        $this->newActivityForUser($activityKey, $bookId)->forceFill([
-            'extra' => $message
-        ])->save();
+        if ($detail instanceof Loggable) {
+            $detail = $detail->logDescriptor();
+        }
 
-        $this->setNotification($activityKey);
+        $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 $key, ?int $bookId = null): Activity
+    protected function newActivityForUser(string $type): Activity
     {
+        $ip = request()->ip() ?? '';
+
         return $this->activity->newInstance()->forceFill([
-            'key' => strtolower($key),
-            'user_id' => $this->user->id,
-            'book_id' => $bookId ?? 0,
+            'type'     => strtolower($type),
+            'user_id'  => user()->id,
+            'ip'       => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
         ]);
     }
 
@@ -60,15 +69,13 @@ class ActivityService
      * and instead uses the 'extra' field with the entities name.
      * Used when an entity is deleted.
      */
-    public function removeEntity(Entity $entity): Collection
+    public function removeEntity(Entity $entity)
     {
-        $activities = $entity->activity()->get();
         $entity->activity()->update([
-            'extra' => $entity->name,
-            'entity_id' => 0,
-            'entity_type' => '',
+            'detail'       => $entity->name,
+            'entity_id'    => null,
+            'entity_type'  => null,
         ]);
-        return $activities;
     }
 
     /**
@@ -77,7 +84,7 @@ class ActivityService
     public function latest(int $count = 20, int $page = 0): array
     {
         $activityList = $this->permissionService
-            ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
+            ->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
             ->orderBy('created_at', 'desc')
             ->with(['user', 'entity'])
             ->skip($count * $page)
@@ -93,17 +100,30 @@ class ActivityService
      */
     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')) {
-            $query = $this->activity->newQuery()->where('book_id', '=', $entity->id);
-        } else {
-            $query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
-                ->where('entity_id', '=', $entity->id);
+            $queryIds[(new Chapter())->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
         }
-        
-        $activity = $this->permissionService
-            ->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
-            ->orderBy('created_at', 'desc')
-            ->with(['entity', 'user.avatar'])
+        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();
@@ -117,7 +137,7 @@ class ActivityService
     public function userActivity(User $user, int $count = 20, int $page = 0): array
     {
         $activityList = $this->permissionService
-            ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
+            ->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
             ->orderBy('created_at', 'desc')
             ->where('user_id', '=', $user->id)
             ->skip($count * $page)
@@ -129,7 +149,9 @@ class ActivityService
 
     /**
      * Filters out similar activity.
+     *
      * @param Activity[] $activities
+     *
      * @return array
      */
     protected function filterSimilar(iterable $activities): array
@@ -151,12 +173,28 @@ class ActivityService
     /**
      * Flashes a notification message to the session if an appropriate message is available.
      */
-    protected function setNotification(string $activityKey)
+    protected function setNotification(string $type)
     {
-        $notificationTextKey = 'activities.' . $activityKey . '_notification';
+        $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);
+    }
 }
diff --git a/app/Actions/ActivityType.php b/app/Actions/ActivityType.php
new file mode 100644 (file)
index 0000000..60b1630
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+namespace BookStack\Actions;
+
+class ActivityType
+{
+    const PAGE_CREATE = 'page_create';
+    const PAGE_UPDATE = 'page_update';
+    const PAGE_DELETE = 'page_delete';
+    const PAGE_RESTORE = 'page_restore';
+    const PAGE_MOVE = 'page_move';
+
+    const CHAPTER_CREATE = 'chapter_create';
+    const CHAPTER_UPDATE = 'chapter_update';
+    const CHAPTER_DELETE = 'chapter_delete';
+    const CHAPTER_MOVE = 'chapter_move';
+
+    const BOOK_CREATE = 'book_create';
+    const BOOK_UPDATE = 'book_update';
+    const BOOK_DELETE = 'book_delete';
+    const BOOK_SORT = 'book_sort';
+
+    const BOOKSHELF_CREATE = 'bookshelf_create';
+    const BOOKSHELF_UPDATE = 'bookshelf_update';
+    const BOOKSHELF_DELETE = 'bookshelf_delete';
+
+    const COMMENTED_ON = 'commented_on';
+    const PERMISSIONS_UPDATE = 'permissions_update';
+
+    const SETTINGS_UPDATE = 'settings_update';
+    const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
+
+    const RECYCLE_BIN_EMPTY = 'recycle_bin_empty';
+    const RECYCLE_BIN_RESTORE = 'recycle_bin_restore';
+    const RECYCLE_BIN_DESTROY = 'recycle_bin_destroy';
+
+    const USER_CREATE = 'user_create';
+    const USER_UPDATE = 'user_update';
+    const USER_DELETE = 'user_delete';
+
+    const API_TOKEN_CREATE = 'api_token_create';
+    const API_TOKEN_UPDATE = 'api_token_update';
+    const API_TOKEN_DELETE = 'api_token_delete';
+
+    const ROLE_CREATE = 'role_create';
+    const ROLE_UPDATE = 'role_update';
+    const ROLE_DELETE = 'role_delete';
+
+    const AUTH_PASSWORD_RESET = 'auth_password_reset_request';
+    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';
+}
index 655d452219b8200da95cab132b605d0fc4ea1543..34fd84709ec1d746bd84c2de87854a3845743b01 100644 (file)
@@ -1,38 +1,44 @@
-<?php namespace BookStack\Actions;
+<?php
 
-use BookStack\Ownable;
+namespace BookStack\Actions;
+
+use BookStack\Model;
+use BookStack\Traits\HasCreatorAndUpdater;
+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 Ownable
+class Comment extends Model
 {
+    use HasCreatorAndUpdater;
+
     protected $fillable = ['text', 'parent_id'];
     protected $appends = ['created', 'updated'];
 
     /**
-     * Get the entity that this comment belongs to
-     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+     * Get the entity that this comment belongs to.
      */
-    public function entity()
+    public function entity(): MorphTo
     {
         return $this->morphTo('entity');
     }
 
     /**
      * Check if a comment has been updated since creation.
-     * @return bool
      */
-    public function isUpdated()
+    public function isUpdated(): bool
     {
         return $this->updated_at->timestamp > $this->created_at->timestamp;
     }
 
     /**
      * Get created date as a relative diff.
+     *
      * @return mixed
      */
     public function getCreatedAttribute()
@@ -42,6 +48,7 @@ class Comment extends Ownable
 
     /**
      * Get updated date as a relative diff.
+     *
      * @return mixed
      */
     public function getUpdatedAttribute()
index 4dfe3ddb64f86f3252418b3b83d04ef6367d39b6..85fb6498a92bca35897e65845f00b87e2ba64a95 100644 (file)
@@ -1,20 +1,21 @@
-<?php namespace BookStack\Actions;
+<?php
 
-use BookStack\Entities\Entity;
+namespace BookStack\Actions;
+
+use BookStack\Entities\Models\Entity;
+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;
@@ -44,6 +45,8 @@ class CommentRepo
         $comment->parent_id = $parent_id;
 
         $entity->comments()->save($comment);
+        ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
+
         return $comment;
     }
 
@@ -56,6 +59,7 @@ class CommentRepo
         $comment->text = $text;
         $comment->html = $this->commentToHtml($text);
         $comment->save();
+
         return $comment;
     }
 
@@ -73,8 +77,8 @@ class CommentRepo
     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,
         ]);
 
@@ -87,6 +91,7 @@ class CommentRepo
     protected function getNextLocalId(Entity $entity): int
     {
         $comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
+
         return ($comments->local_id ?? 0) + 1;
     }
 }
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 80a91150868e9cd87be62685891237c392606328..ce0954f00cd347c50e21806caa8949d256a863cd 100644 (file)
@@ -1,22 +1,36 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
 
-/**
- * Class Attribute
- * @package BookStack
- */
 class Tag extends Model
 {
     protected $fillable = ['name', 'value', 'order'];
-    protected $hidden = ['id', 'entity_id', 'entity_type'];
+    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 0297d8bc6997b790085a485b1761085cc946ce59..b892efe577901191c4a7fea292e134eefbff86ea 100644 (file)
@@ -1,13 +1,14 @@
-<?php namespace BookStack\Actions;
+<?php
+
+namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Entity;
-use DB;
+use BookStack\Entities\Models\Entity;
 use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
 
 class TagRepo
 {
-
     protected $tag;
     protected $permissionService;
 
@@ -26,7 +27,9 @@ class TagRepo
      */
     public function getNameSuggestions(?string $searchTerm): Collection
     {
-        $query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name');
+        $query = $this->tag->newQuery()
+            ->select('*', DB::raw('count(*) as count'))
+            ->groupBy('name');
 
         if ($searchTerm) {
             $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@@ -35,6 +38,7 @@ class TagRepo
         }
 
         $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+
         return $query->get(['name'])->pluck('name');
     }
 
@@ -45,7 +49,9 @@ class TagRepo
      */
     public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
     {
-        $query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value');
+        $query = $this->tag->newQuery()
+            ->select('*', DB::raw('count(*) as count'))
+            ->groupBy('value');
 
         if ($searchTerm) {
             $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
@@ -58,11 +64,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
     {
@@ -85,6 +92,7 @@ class TagRepo
     {
         $name = trim($input['name']);
         $value = isset($input['value']) ? trim($input['value']) : '';
+
         return $this->tag->newInstance(['name' => $name, 'value' => $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 324bfaa..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-<?php namespace BookStack\Actions;
-
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Book;
-use BookStack\Entities\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\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, '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');
-    }
-
-    /**
-     * Get all recently viewed entities for the current user.
-     * @param int $count
-     * @param int $page
-     * @param Entity|bool $filterModel
-     * @return mixed
-     */
-    public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
-    {
-        $user = user();
-        if ($user === null || $user->isDefault()) {
-            return collect();
-        }
-
-        $query = $this->permissionService
-            ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
-
-        if ($filterModel) {
-            $query = $query->where('viewable_type', '=', $filterModel->getMorphClass());
-        }
-        $query = $query->where('user_id', '=', $user->id);
-
-        $viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
-            ->skip($count * $page)->take($count)->get()->pluck('viewable');
-        return $viewables;
-    }
-
-    /**
-     * Reset all view counts by deleting all views.
-     */
-    public function resetAll()
-    {
-        $this->view->truncate();
-    }
-}
index ddba24bdb65d6ec8dc1474e3d50996c546623228..0ed7e6712d3106c93d52478c1b362fa0adbc3cab 100644 (file)
@@ -1,7 +1,11 @@
-<?php namespace BookStack\Api;
+<?php
+
+namespace BookStack\Api;
 
 use BookStack\Http\Controllers\Api\ApiController;
+use Illuminate\Contracts\Container\BindingResolutionException;
 use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Str;
 use ReflectionClass;
@@ -10,19 +14,37 @@ use ReflectionMethod;
 
 class ApiDocsGenerator
 {
-
     protected $reflectionClasses = [];
     protected $controllerClasses = [];
 
+    /**
+     * Load the docs form the cache if existing
+     * otherwise generate and store in the cache.
+     */
+    public static function generateConsideringCache(): Collection
+    {
+        $appVersion = trim(file_get_contents(base_path('version')));
+        $cacheKey = 'api-docs::' . $appVersion;
+        if (Cache::has($cacheKey) && config('app.env') === 'production') {
+            $docs = Cache::get($cacheKey);
+        } else {
+            $docs = (new static())->generate();
+            Cache::put($cacheKey, $docs, 60 * 24);
+        }
+
+        return $docs;
+    }
+
     /**
      * Generate API documentation.
      */
-    public function generate(): Collection
+    protected function generate(): Collection
     {
         $apiRoutes = $this->getFlatApiRoutes();
         $apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
         $apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
         $apiRoutes = $apiRoutes->groupBy('base_model');
+
         return $apiRoutes;
     }
 
@@ -38,6 +60,7 @@ class ApiDocsGenerator
                 $exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
                 $route["example_{$exampleType}"] = $exampleContent;
             }
+
             return $route;
         });
     }
@@ -52,13 +75,15 @@ 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 \Illuminate\Contracts\Container\BindingResolutionException
+     *
+     * @throws BindingResolutionException
      */
     protected function getBodyParamsFromClass(string $className, string $methodName): ?array
     {
@@ -73,6 +98,7 @@ class ApiDocsGenerator
         foreach ($rules as $param => $ruleString) {
             $rules[$param] = explode('|', $ruleString);
         }
+
         return count($rules) > 0 ? $rules : null;
     }
 
@@ -83,11 +109,13 @@ class ApiDocsGenerator
     {
         $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
@@ -112,16 +140,16 @@ 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,
             ];
         });
     }
-
-}
\ No newline at end of file
+}
index 523c3b8b80ec2a883e590b73cbf701d2d7e67829..f44fde19aa3689eff26ef91f2f1f22e3c856caf5 100644 (file)
@@ -1,15 +1,28 @@
-<?php namespace BookStack\Api;
+<?php
+
+namespace BookStack\Api;
 
 use BookStack\Auth\User;
+use BookStack\Interfaces\Loggable;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Support\Carbon;
 
-class ApiToken extends Model
+/**
+ * Class ApiToken.
+ *
+ * @property int    $id
+ * @property string $token_id
+ * @property string $secret
+ * @property string $name
+ * @property Carbon $expires_at
+ * @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',
     ];
 
     /**
@@ -28,4 +41,12 @@ class ApiToken extends Model
     {
         return Carbon::now()->addYears(100)->format('Y-m-d');
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
+    }
 }
index e0a50ebe3d2a03f7e90a8b4362ff15283bb73849..8b9cbc8e1b44ebff4ed2586336bc74b19aed600d 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,11 +35,12 @@ 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
      */
@@ -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
@@ -163,4 +178,4 @@ class ApiTokenGuard implements Guard
     {
         $this->user = null;
     }
-}
\ No newline at end of file
+}
index df4cb8bf1ae98904622a7cc2fbaa6770f197604a..02b3f680cf0a3bc1f362313468dfad88ff7401a0 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;
@@ -18,7 +19,7 @@ class ListingResponseBuilder
         'lt'   => '<',
         'gte'  => '>=',
         'lte'  => '<=',
-        'like' => 'like'
+        'like' => 'like',
     ];
 
     /**
@@ -42,7 +43,7 @@ class ListingResponseBuilder
         $data = $this->fetchData($filteredQuery);
 
         return response()->json([
-            'data' => $data,
+            'data'  => $data,
             'total' => $total,
         ]);
     }
@@ -54,6 +55,7 @@ class ListingResponseBuilder
     {
         $query = $this->countAndOffsetQuery($query);
         $query = $this->sortQuery($query);
+
         return $query->get($this->fields);
     }
 
@@ -95,6 +97,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 7f15307aea4f54ba97f3432356d345b9478f24ad..b0c9e8e7b7cda5a730181d871d410ca907750488 100644 (file)
@@ -1,7 +1,10 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
+use Illuminate\Support\Collection;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Str;
 
@@ -25,15 +28,15 @@ class ExternalAuthService
      */
     protected function getOrRegisterUser(array $userDetails): ?User
     {
-        $user = $this->user->newQuery()
+        $user = User::query()
           ->where('external_auth_id', '=', $userDetails['external_id'])
           ->first();
 
         if (is_null($user)) {
             $userData = [
-                'name' => $userDetails['name'],
-                'email' => $userDetails['email'],
-                'password' => Str::random(32),
+                'name'             => $userDetails['name'],
+                'email'            => $userDetails['email'],
+                'password'         => Str::random(32),
                 'external_auth_id' => $userDetails['external_id'],
             ];
 
@@ -54,6 +57,7 @@ class ExternalAuthService
         }
 
         $roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
+
         return in_array($roleName, $groupNames);
     }
 
@@ -76,22 +80,14 @@ class ExternalAuthService
     /**
      * Match an array of group names to BookStack system roles.
      * Formats group names to be lower-case and hyphenated.
-     * @param array $groupNames
-     * @return \Illuminate\Support\Collection
      */
-    protected function matchGroupsToSystemsRoles(array $groupNames)
+    protected function matchGroupsToSystemsRoles(array $groupNames): Collection
     {
         foreach ($groupNames as $i => $groupName) {
             $groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
         }
 
-        $roles = Role::query()->where(function (Builder $query) use ($groupNames) {
-            $query->whereIn('name', $groupNames);
-            foreach ($groupNames as $groupName) {
-                $query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
-            }
-        })->get();
-
+        $roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
         $matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
             return $this->roleMatchesGroupNames($role, $groupNames);
         });
@@ -100,7 +96,7 @@ 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
     {
index 69295ee4e900188cc2c7c3097b0a3c3379195b57..fde610c3e18746f33b616c4b309a91327eb77d91 100644 (file)
@@ -7,7 +7,6 @@ use Illuminate\Contracts\Auth\UserProvider;
 
 class ExternalBaseUserProvider implements UserProvider
 {
-
     /**
      * The user model.
      *
@@ -17,7 +16,8 @@ class ExternalBaseUserProvider implements UserProvider
 
     /**
      * LdapUserProvider constructor.
-     * @param             $model
+     *
+     * @param $model
      */
     public function __construct(string $model)
     {
@@ -32,13 +32,15 @@ class ExternalBaseUserProvider implements UserProvider
     public function createModel()
     {
         $class = '\\' . ltrim($this->model, '\\');
-        return new $class;
+
+        return new $class();
     }
 
     /**
      * Retrieve a user by their unique identifier.
      *
-     * @param  mixed $identifier
+     * @param mixed $identifier
+     *
      * @return \Illuminate\Contracts\Auth\Authenticatable|null
      */
     public function retrieveById($identifier)
@@ -49,8 +51,9 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Retrieve a user by their unique identifier and "remember me" token.
      *
-     * @param  mixed  $identifier
-     * @param  string $token
+     * @param mixed  $identifier
+     * @param string $token
+     *
      * @return \Illuminate\Contracts\Auth\Authenticatable|null
      */
     public function retrieveByToken($identifier, $token)
@@ -58,12 +61,12 @@ class ExternalBaseUserProvider implements UserProvider
         return null;
     }
 
-
     /**
      * Update the "remember me" token for the given user in storage.
      *
-     * @param  \Illuminate\Contracts\Auth\Authenticatable $user
-     * @param  string                                     $token
+     * @param \Illuminate\Contracts\Auth\Authenticatable $user
+     * @param string                                     $token
+     *
      * @return void
      */
     public function updateRememberToken(Authenticatable $user, $token)
@@ -74,13 +77,15 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Retrieve a user by the given credentials.
      *
-     * @param  array $credentials
+     * @param array $credentials
+     *
      * @return \Illuminate\Contracts\Auth\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 +94,9 @@ class ExternalBaseUserProvider implements UserProvider
     /**
      * Validate a user against the given credentials.
      *
-     * @param  \Illuminate\Contracts\Auth\Authenticatable $user
-     * @param  array                                      $credentials
+     * @param \Illuminate\Contracts\Auth\Authenticatable $user
+     * @param array                                      $credentials
+     *
      * @return bool
      */
     public function validateCredentials(Authenticatable $user, array $credentials)
index f3d05366d9544ee5dde9c1a17304199b83f834c7..99bfd2e795ecd4f12198d21ea2ae97d39062af87 100644 (file)
@@ -15,8 +15,6 @@ use Illuminate\Contracts\Session\Session;
  * guard with 'remember' functionality removed. Basic auth and event emission
  * has also been removed to keep this simple. Designed to be extended by external
  * Auth Guards.
- *
- * @package Illuminate\Auth
  */
 class ExternalBaseSessionGuard implements StatefulGuard
 {
@@ -86,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;
         }
 
@@ -94,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);
         }
 
@@ -120,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 = [])
@@ -137,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;
@@ -154,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 = [])
@@ -162,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)
@@ -178,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)
@@ -210,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)
@@ -264,7 +264,7 @@ class ExternalBaseSessionGuard implements StatefulGuard
      */
     public function getName()
     {
-        return 'login_'.$this->name.'_'.sha1(static::class);
+        return 'login_' . $this->name . '_' . sha1(static::class);
     }
 
     /**
@@ -290,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)
@@ -301,5 +302,4 @@ class ExternalBaseSessionGuard implements StatefulGuard
 
         return $this;
     }
-
 }
index 652141c0ce280963abc337f20cd62e19d79e6f40..7f6965937a19929a43124c508de406e8c61eb90a 100644 (file)
@@ -5,31 +5,28 @@ namespace BookStack\Auth\Access\Guards;
 use BookStack\Auth\Access\LdapService;
 use BookStack\Auth\Access\RegistrationService;
 use BookStack\Auth\User;
-use BookStack\Auth\UserRepo;
 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;
-use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 
 class LdapSessionGuard extends ExternalBaseSessionGuard
 {
-
     protected $ldapService;
 
     /**
      * LdapSessionGuard constructor.
      */
-    public function __construct($name,
+    public function __construct(
+        $name,
         UserProvider $provider,
         Session $session,
         LdapService $ldapService,
         RegistrationService $registrationService
-    )
-    {
+    ) {
         $this->ldapService = $ldapService;
         parent::__construct($name, $provider, $session, $registrationService);
     }
@@ -38,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 = [])
     {
@@ -47,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'],
             ]);
         }
 
@@ -58,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)
     {
@@ -71,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'],
             ]);
         }
 
@@ -92,12 +93,19 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
             $this->ldapService->syncGroups($user, $username);
         }
 
+        // Attach avatar if non-existent
+        if (is_null($user->avatar)) {
+            $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
@@ -111,13 +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 4023913ed77bb47caac93d5dc0d84a6673be4f90..eacd5d21e702f13efcbc654589a932a945131661 100644 (file)
@@ -3,14 +3,12 @@
 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.
- *
- * @package BookStack\Auth\Access\Guards
  */
 class Saml2SessionGuard extends ExternalBaseSessionGuard
 {
@@ -18,6 +16,7 @@ class Saml2SessionGuard extends ExternalBaseSessionGuard
      * Validate a user's credentials.
      *
      * @param array $credentials
+     *
      * @return bool
      */
     public function validate(array $credentials = [])
@@ -29,12 +28,12 @@ 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)
     {
         return false;
     }
-
 }
index 843a2f204920e9bd5154efe477c5212f73efa057..b5c70e498c0b4581dae5b6772ecee6f4c20696ca 100644 (file)
@@ -1,18 +1,20 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 /**
  * Class Ldap
  * An object-orientated thin abstraction wrapper for common PHP LDAP functions.
  * Allows the standard LDAP functions to be mocked for testing.
- * @package BookStack\Services
  */
 class Ldap
 {
-
     /**
      * Connect to a LDAP server.
+     *
      * @param string $hostName
      * @param int    $port
+     *
      * @return resource
      */
     public function connect($hostName, $port)
@@ -22,9 +24,11 @@ class Ldap
 
     /**
      * Set the value of a LDAP option for the given connection.
+     *
      * @param resource $ldapConnection
-     * @param int $option
-     * @param mixed $value
+     * @param int      $option
+     * @param mixed    $value
+     *
      * @return bool
      */
     public function setOption($ldapConnection, $option, $value)
@@ -32,10 +36,20 @@ class Ldap
         return ldap_set_option($ldapConnection, $option, $value);
     }
 
+    /**
+     * Start TLS on the given LDAP connection.
+     */
+    public function startTls($ldapConnection): bool
+    {
+        return ldap_start_tls($ldapConnection);
+    }
+
     /**
      * Set the version number for the given ldap connection.
+     *
      * @param $ldapConnection
      * @param $version
+     *
      * @return bool
      */
     public function setVersion($ldapConnection, $version)
@@ -45,10 +59,12 @@ class Ldap
 
     /**
      * 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)
@@ -58,8 +74,10 @@ class Ldap
 
     /**
      * Get entries from an ldap search result.
+     *
      * @param resource $ldapConnection
      * @param resource $ldapSearchResult
+     *
      * @return array
      */
     public function getEntries($ldapConnection, $ldapSearchResult)
@@ -69,23 +87,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)
@@ -95,8 +118,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)
@@ -106,12 +131,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 92234edcf906bc2934a47443af011de388d2f853..7bfdb5328d874e5296f0227253a45475b2baddd5 100644 (file)
@@ -1,9 +1,13 @@
-<?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
@@ -11,24 +15,26 @@ use ErrorException;
  */
 class LdapService extends ExternalAuthService
 {
-
     protected $ldap;
     protected $ldapConnection;
+    protected $userAvatars;
     protected $config;
     protected $enabled;
 
     /**
      * LdapService constructor.
      */
-    public function __construct(Ldap $ldap)
+    public function __construct(Ldap $ldap, UserAvatars $userAvatars)
     {
         $this->ldap = $ldap;
+        $this->userAvatars = $userAvatars;
         $this->config = config('services.ldap');
         $this->enabled = config('auth.method') === 'ldap';
     }
 
     /**
      * Check if groups should be synced.
+     *
      * @return bool
      */
     public function shouldSyncGroups()
@@ -38,6 +44,7 @@ class LdapService extends ExternalAuthService
 
     /**
      * 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,10 +84,13 @@ 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;
         }
 
@@ -89,11 +100,12 @@ class LdapService extends ExternalAuthService
             '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
+     *
      * @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()
     {
@@ -187,8 +205,8 @@ class LdapService extends ExternalAuthService
             throw new LdapException(trans('errors.ldap_extension_not_installed'));
         }
 
-         // Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
-         // the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
+        // Disable certificate verification.
+        // This option works globally and must be set before a connection is created.
         if ($this->config['tls_insecure']) {
             $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
         }
@@ -205,7 +223,16 @@ class LdapService extends ExternalAuthService
             $this->ldap->setVersion($ldapConnection, $this->config['version']);
         }
 
+        // Start and verify TLS if it's enabled
+        if ($this->config['start_tls']) {
+            $started = $this->ldap->startTls($ldapConnection);
+            if (!$started) {
+                throw new LdapException('Could not start TLS connection');
+            }
+        }
+
         $this->ldapConnection = $ldapConnection;
+
         return $this->ldapConnection;
     }
 
@@ -225,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];
     }
 
@@ -238,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
@@ -256,11 +286,13 @@ class LdapService extends ExternalAuthService
 
         $userGroups = $this->groupFilter($user);
         $userGroups = $this->getGroupsRecursive($userGroups, []);
+
         return $userGroups;
     }
 
     /**
      * Get the parent groups of an array of groups.
+     *
      * @throws LdapException
      */
     private function getGroupsRecursive(array $groupsArray, array $checked): array
@@ -287,6 +319,7 @@ class LdapService extends ExternalAuthService
 
     /**
      * Get the parent groups of a single group.
+     *
      * @throws LdapException
      */
     private function getGroupGroups(string $groupName): array
@@ -320,7 +353,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++) {
@@ -335,6 +368,7 @@ 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)
@@ -342,4 +376,22 @@ class LdapService extends ExternalAuthService
         $userLdapGroups = $this->getUserGroups($username);
         $this->syncWithGroups($user, $userLdapGroups);
     }
+
+    /**
+     * 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..b36adb5
--- /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', 'openid'];
+            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..0d9bd37
--- /dev/null
@@ -0,0 +1,72 @@
+<?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 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): 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');
+    }
+}
index 9136b37b5a86ea49f1f0a8ccaac27b1fd25eb27d..16e3edbb44e8dc799c4bd3939a358d5d3b9b28df 100644 (file)
@@ -1,14 +1,19 @@
-<?php namespace BookStack\Auth\Access;
+<?php
 
+namespace BookStack\Auth\Access;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\SocialAccount;
 use BookStack\Auth\User;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
 use Exception;
 
 class RegistrationService
 {
-
     protected $userRepo;
     protected $emailConfirmationService;
 
@@ -23,6 +28,7 @@ class RegistrationService
 
     /**
      * Check whether or not registrations are allowed in the app settings.
+     *
      * @throws UserRegistrationException
      */
     public function ensureRegistrationAllowed()
@@ -40,11 +46,13 @@ class RegistrationService
     {
         $authMethod = config('auth.method');
         $authMethodsWithRegistration = ['standard'];
+
         return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
     }
 
     /**
      * The registrations flow for all users.
+     *
      * @throws UserRegistrationException
      */
     public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
@@ -57,7 +65,7 @@ class RegistrationService
         // Ensure user does not already exist
         $alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
         if ($alreadyUser) {
-            throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]));
+            throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
         }
 
         // Create the user
@@ -68,18 +76,21 @@ class RegistrationService
             $newUser->socialAccounts()->save($socialAccount);
         }
 
+        Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
+        Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
+
         // Start email confirmation flow if required
         if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
             $newUser->save();
-            $message = '';
 
             try {
                 $this->emailConfirmationService->sendConfirmation($newUser);
+                session()->flash('sent-email-confirmation', true);
             } catch (Exception $e) {
                 $message = trans('auth.email_confirm_send_error');
-            }
 
-            throw new UserRegistrationException($message, '/register/confirm');
+                throw new UserRegistrationException($message, '/register/confirm');
+            }
         }
 
         return $newUser;
@@ -88,6 +99,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
@@ -99,11 +111,11 @@ 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);
         }
     }
-
-}
\ No newline at end of file
+}
index 4c1fce8643184b476ae5646d0b9855894f3a911a..74e8c7726e4280e58f89af532fe9b8a29dbacdc3 100644 (file)
@@ -1,8 +1,11 @@
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\User;
 use BookStack\Exceptions\JsonDebugException;
 use BookStack\Exceptions\SamlException;
+use BookStack\Exceptions\StoppedAuthenticationException;
 use BookStack\Exceptions\UserRegistrationException;
 use Exception;
 use OneLogin\Saml2\Auth;
@@ -17,33 +20,40 @@ use OneLogin\Saml2\ValidationError;
 class Saml2Service extends ExternalAuthService
 {
     protected $config;
+    protected $registrationService;
+    protected $loginService;
 
     /**
      * Saml2Service constructor.
      */
-    public function __construct(RegistrationService $registrationService, User $user)
+    public function __construct(RegistrationService $registrationService, LoginService $loginService, User $user),
     {
         parent::__construct($registrationService, $user);
         
         $this->config = config('saml2');
+        $this->registrationService = $registrationService;
+        $this->loginService = $loginService;
     }
 
     /**
      * 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
@@ -71,6 +81,7 @@ 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
@@ -85,7 +96,7 @@ class Saml2Service extends ExternalAuthService
 
         if (!empty($errors)) {
             throw new Error(
-                'Invalid ACS Response: '.implode(', ', $errors)
+                'Invalid ACS Response: ' . implode(', ', $errors)
             );
         }
 
@@ -101,6 +112,7 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Process a response for the single logout service.
+     *
      * @throws Error
      */
     public function processSlsResponse(?string $requestId): ?string
@@ -112,11 +124,12 @@ class Saml2Service extends ExternalAuthService
 
         if (!empty($errors)) {
             throw new Error(
-                'Invalid SLS Response: '.implode(', ', $errors)
+                'Invalid SLS Response: ' . implode(', ', $errors)
             );
         }
 
         $this->actionLogout();
+
         return $redirect;
     }
 
@@ -131,6 +144,7 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Get the metadata for this service provider.
+     *
      * @throws Error
      */
     public function metadata(): string
@@ -142,7 +156,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
             );
         }
@@ -152,6 +166,7 @@ class Saml2Service extends ExternalAuthService
 
     /**
      * Load the underlying Onelogin SAML2 toolkit.
+     *
      * @throws Error
      * @throws Exception
      */
@@ -171,6 +186,7 @@ class Saml2Service extends ExternalAuthService
 
         $spSettings = $this->loadOneloginServiceProviderDetails();
         $settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
+
         return new Auth($settings);
     }
 
@@ -180,18 +196,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,
         ];
     }
 
@@ -204,7 +220,7 @@ class Saml2Service extends ExternalAuthService
     }
 
     /**
-     * Calculate the display name
+     * Calculate the display name.
      */
     protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
     {
@@ -254,9 +270,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,
         ];
     }
 
@@ -290,6 +306,7 @@ class Saml2Service extends ExternalAuthService
                 $data = $data[0];
                 break;
         }
+
         return $data;
     }
 
@@ -309,9 +326,11 @@ class Saml2Service extends ExternalAuthService
     /**
      * 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
     {
@@ -320,8 +339,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,
             ]);
         }
@@ -344,7 +363,8 @@ class Saml2Service extends ExternalAuthService
             $this->syncWithGroups($user, $groups);
         }
 
-        auth()->login($user);
+        $this->loginService->login($user, 'saml2');
+
         return $user;
     }
 }
index 657aae3f327d530557b37c4ff2ce0f6f7126114a..d165e76b121bbe2b6f5064c1b844906272d04f99 100644 (file)
-<?php namespace BookStack\Auth\Access;
+<?php
+
+namespace BookStack\Auth\Access;
 
 use BookStack\Auth\SocialAccount;
-use BookStack\Auth\UserRepo;
+use BookStack\Auth\User;
 use BookStack\Exceptions\SocialDriverNotConfigured;
 use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use BookStack\Exceptions\UserRegistrationException;
+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 SocialiteProviders\Manager\SocialiteWasCalled;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
 class SocialAuthService
 {
-
-    protected $userRepo;
+    /**
+     * 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(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
+    public function __construct(Socialite $socialite, LoginService $loginService)
     {
-        $this->userRepo = $userRepo;
         $this->socialite = $socialite;
-        $this->socialAccount = $socialAccount;
+        $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
     {
         // Check social account has not already been used
-        if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
-            throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver]), '/login');
+        if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) {
+            throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login');
         }
 
-        if ($this->userRepo->getByEmail($socialUser->getEmail())) {
+        if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) {
             $email = $socialUser->getEmail();
+
             throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
         }
 
@@ -72,16 +113,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)
@@ -89,7 +133,7 @@ class SocialAuthService
         $socialId = $socialUser->getId();
 
         // Get any attached social accounts or users
-        $socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
+        $socialAccount = SocialAccount::query()->where('driver_id', '=', $socialId)->first();
         $isLoggedIn = auth()->check();
         $currentUser = user();
         $titleCaseDriver = Str::title($socialDriver);
@@ -97,28 +141,32 @@ 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);
+            $this->loginService->login($socialAccount->user, $socialDriver);
+
             return redirect()->intended('/');
         }
 
         // When a user is logged in but the social account does not exist,
         // Create the social account and attach it to the user & redirect to the profile page.
         if ($isLoggedIn && $socialAccount === null) {
-            $this->fillSocialAccount($socialDriver, $socialUser);
-            $currentUser->socialAccounts()->save($this->socialAccount);
+            $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());
         }
 
@@ -127,12 +175,13 @@ class SocialAuthService
         if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
             $message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
         }
-        
+
         throw new SocialSignInAccountNotUsed($message, '/login');
     }
 
     /**
      * Ensure the social driver is correct and supported.
+     *
      * @throws SocialDriverNotConfigured
      */
     protected function validateDriver(string $socialDriver): string
@@ -158,6 +207,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);
     }
 
@@ -204,29 +254,27 @@ class SocialAuthService
     /**
      * Fill and return a SocialAccount from the given driver name and SocialUser.
      */
-    public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
+    public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
     {
-        $this->socialAccount->fill([
+        return new SocialAccount([
             'driver'    => $socialDriver,
             'driver_id' => $socialUser->getId(),
-            'avatar'    => $socialUser->getAvatar()
+            'avatar'    => $socialUser->getAvatar(),
         ]);
-        return $this->socialAccount;
     }
 
     /**
      * Detach a social account from a user.
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
      */
-    public function detachSocialAccount(string $socialDriver)
+    public function detachSocialAccount(string $socialDriver): void
     {
         user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
     }
 
     /**
-     * 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);
 
@@ -237,6 +285,33 @@ class SocialAuthService
             $driver->with(['resource' => 'https://graph.windows.net']);
         }
 
+        if (isset($this->configureForRedirectCallbacks[$driverName])) {
+            $this->configureForRedirectCallbacks[$driverName]($driver);
+        }
+
         return $driver;
     }
+
+    /**
+     * Add a custom socialite driver to be used.
+     * Driver name should be lower_snake_case.
+     * Config array should mirror the structure of a service
+     * 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,
+        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 c48549b8f00c5cac2e9996fa9c4c4e0d483af365..e10c560d0fdcfee557690939ad23328093689dcc 100644 (file)
@@ -1,27 +1,30 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Auth\Role;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphOne;
 
 class JointPermission extends Model
 {
+    protected $primaryKey = null;
     public $timestamps = false;
 
     /**
      * Get the role that this points to.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function role()
+    public function role(): BelongsTo
     {
         return $this->belongsTo(Role::class);
     }
 
     /**
      * Get the entity this points to.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphOne
      */
-    public function entity()
+    public function entity(): MorphOne
     {
         return $this->morphOne(Entity::class, 'entity');
     }
index 97cc1ca241e84209f235136550378f3cb8f43f81..139725339717edb04175d64a8e849b0226afe41d 100644 (file)
@@ -1,79 +1,56 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
-use BookStack\Auth\Permissions;
 use BookStack\Auth\Role;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\EntityProvider;
-use BookStack\Entities\Page;
-use BookStack\Ownable;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+use BookStack\Traits\HasCreatorAndUpdater;
+use BookStack\Traits\HasOwner;
 use Illuminate\Database\Connection;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection as EloquentCollection;
 use Illuminate\Database\Query\Builder as QueryBuilder;
-use Illuminate\Support\Collection;
+use Throwable;
 
 class PermissionService
 {
-
-    protected $currentAction;
-    protected $isAdminUser;
-    protected $userRoles = false;
-    protected $currentUserModel = false;
-
-    /**
-     * @var Connection
-     */
-    protected $db;
-
     /**
-     * @var JointPermission
+     * @var ?array
      */
-    protected $jointPermission;
+    protected $userRoles = null;
 
     /**
-     * @var Role
+     * @var ?User
      */
-    protected $role;
+    protected $currentUserModel = null;
 
     /**
-     * @var EntityPermission
+     * @var Connection
      */
-    protected $entityPermission;
+    protected $db;
 
     /**
-     * @var EntityProvider
+     * @var array
      */
-    protected $entityProvider;
-
     protected $entityCache;
 
     /**
      * PermissionService constructor.
-     * @param JointPermission $jointPermission
-     * @param EntityPermission $entityPermission
-     * @param Role $role
-     * @param Connection $db
-     * @param EntityProvider $entityProvider
-     */
-    public function __construct(
-        JointPermission $jointPermission,
-        Permissions\EntityPermission $entityPermission,
-        Role $role,
-        Connection $db,
-        EntityProvider $entityProvider
-    ) {
+     */
+    public function __construct(Connection $db)
+    {
         $this->db = $db;
-        $this->jointPermission = $jointPermission;
-        $this->entityPermission = $entityPermission;
-        $this->role = $role;
-        $this->entityProvider = $entityProvider;
     }
 
     /**
-     * Set the database connection
-     * @param Connection $connection
+     * Set the database connection.
      */
     public function setConnection(Connection $connection)
     {
@@ -81,82 +58,65 @@ class PermissionService
     }
 
     /**
-     * Prepare the local entity cache and ensure it's empty
-     * @param \BookStack\Entities\Entity[] $entities
+     * Prepare the local entity cache and ensure it's empty.
+     *
+     * @param Entity[] $entities
      */
-    protected function readyEntityCache($entities = [])
+    protected function readyEntityCache(array $entities = [])
     {
         $this->entityCache = [];
 
         foreach ($entities as $entity) {
-            $type = $entity->getType();
-            if (!isset($this->entityCache[$type])) {
-                $this->entityCache[$type] = collect();
+            $class = get_class($entity);
+            if (!isset($this->entityCache[$class])) {
+                $this->entityCache[$class] = collect();
             }
-            $this->entityCache[$type]->put($entity->id, $entity);
+            $this->entityCache[$class]->put($entity->id, $entity);
         }
     }
 
     /**
-     * Get a book via ID, Checks local cache
-     * @param $bookId
-     * @return Book
+     * Get a book via ID, Checks local cache.
      */
-    protected function getBook($bookId)
+    protected function getBook(int $bookId): ?Book
     {
-        if (isset($this->entityCache['book']) && $this->entityCache['book']->has($bookId)) {
-            return $this->entityCache['book']->get($bookId);
-        }
-
-        $book = $this->entityProvider->book->find($bookId);
-        if ($book === null) {
-            $book = false;
+        if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
+            return $this->entityCache[Book::class]->get($bookId);
         }
 
-        return $book;
+        return Book::query()->withTrashed()->find($bookId);
     }
 
     /**
-     * Get a chapter via ID, Checks local cache
-     * @param $chapterId
-     * @return \BookStack\Entities\Book
+     * Get a chapter via ID, Checks local cache.
      */
-    protected function getChapter($chapterId)
+    protected function getChapter(int $chapterId): ?Chapter
     {
-        if (isset($this->entityCache['chapter']) && $this->entityCache['chapter']->has($chapterId)) {
-            return $this->entityCache['chapter']->get($chapterId);
+        if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
+            return $this->entityCache[Chapter::class]->get($chapterId);
         }
 
-        $chapter = $this->entityProvider->chapter->find($chapterId);
-        if ($chapter === null) {
-            $chapter = false;
-        }
-
-        return $chapter;
+        return Chapter::query()
+            ->withTrashed()
+            ->find($chapterId);
     }
 
     /**
-     * Get the roles for the current user;
-     * @return array|bool
+     * Get the roles for the current logged in user.
      */
-    protected function getRoles()
+    protected function getCurrentUserRoles(): array
     {
-        if ($this->userRoles !== false) {
+        if (!is_null($this->userRoles)) {
             return $this->userRoles;
         }
 
-        $roles = [];
-
         if (auth()->guest()) {
-            $roles[] = $this->role->getSystemRole('public')->id;
-            return $roles;
+            $this->userRoles = [Role::getSystemRole('public')->id];
+        } else {
+            $this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
         }
 
-
-        foreach ($this->currentUser()->roles as $role) {
-            $roles[] = $role->id;
-        }
-        return $roles;
+        return $this->userRoles;
     }
 
     /**
@@ -164,59 +124,59 @@ class PermissionService
      */
     public function buildJointPermissions()
     {
-        $this->jointPermission->truncate();
+        JointPermission::query()->truncate();
         $this->readyEntityCache();
 
         // Get all roles (Should be the most limited dimension)
-        $roles = $this->role->with('permissions')->get()->all();
+        $roles = Role::query()->with('permissions')->get()->all();
 
         // Chunk through all books
-        $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
+        $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
             $this->buildJointPermissionsForBooks($books, $roles);
         });
 
         // Chunk through all bookshelves
-        $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
-            ->chunk(50, function ($shelves) use ($roles) {
+        Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
+            ->chunk(50, function (EloquentCollection $shelves) use ($roles) {
                 $this->buildJointPermissionsForShelves($shelves, $roles);
             });
     }
 
     /**
      * Get a query for fetching a book with it's children.
-     * @return QueryBuilder
      */
-    protected function bookFetchQuery()
+    protected function bookFetchQuery(): Builder
     {
-        return $this->entityProvider->book->newQuery()
-            ->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
-                $query->select(['id', 'restricted', 'created_by', 'book_id']);
-            }, 'pages'  => function ($query) {
-                $query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
-            }]);
+        return Book::query()->withTrashed()
+            ->select(['id', 'restricted', 'owned_by'])->with([
+                'chapters' => function ($query) {
+                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
+                },
+                'pages' => function ($query) {
+                    $query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
+                },
+            ]);
     }
 
     /**
-     * @param Collection $shelves
-     * @param array $roles
-     * @param bool $deleteOld
-     * @throws \Throwable
+     * Build joint permissions for the given shelf and role combinations.
+     *
+     * @throws Throwable
      */
-    protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
+    protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
     {
         if ($deleteOld) {
             $this->deleteManyJointPermissionsForEntities($shelves->all());
         }
-        $this->createManyJointPermissions($shelves, $roles);
+        $this->createManyJointPermissions($shelves->all(), $roles);
     }
 
     /**
-     * Build joint permissions for an array of books
-     * @param Collection $books
-     * @param array $roles
-     * @param bool $deleteOld
+     * Build joint permissions for the given book and role combinations.
+     *
+     * @throws Throwable
      */
-    protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
+    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
     {
         $entities = clone $books;
 
@@ -233,55 +193,56 @@ class PermissionService
         if ($deleteOld) {
             $this->deleteManyJointPermissionsForEntities($entities->all());
         }
-        $this->createManyJointPermissions($entities, $roles);
+        $this->createManyJointPermissions($entities->all(), $roles);
     }
 
     /**
      * Rebuild the entity jointPermissions for a particular entity.
-     * @param \BookStack\Entities\Entity $entity
-     * @throws \Throwable
+     *
+     * @throws Throwable
      */
     public function buildJointPermissionsForEntity(Entity $entity)
     {
         $entities = [$entity];
-        if ($entity->isA('book')) {
+        if ($entity instanceof Book) {
             $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
-            $this->buildJointPermissionsForBooks($books, $this->role->newQuery()->get(), true);
+            $this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
+
             return;
         }
 
+        /** @var BookChild $entity */
         if ($entity->book) {
             $entities[] = $entity->book;
         }
 
-        if ($entity->isA('page') && $entity->chapter_id) {
+        if ($entity instanceof Page && $entity->chapter_id) {
             $entities[] = $entity->chapter;
         }
 
-        if ($entity->isA('chapter')) {
+        if ($entity instanceof Chapter) {
             foreach ($entity->pages as $page) {
                 $entities[] = $page;
             }
         }
 
-        $this->buildJointPermissionsForEntities(collect($entities));
+        $this->buildJointPermissionsForEntities($entities);
     }
 
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
-     * @param Collection $entities
-     * @throws \Throwable
+     *
+     * @throws Throwable
      */
-    public function buildJointPermissionsForEntities(Collection $entities)
+    public function buildJointPermissionsForEntities(array $entities)
     {
-        $roles = $this->role->newQuery()->get();
-        $this->deleteManyJointPermissionsForEntities($entities->all());
+        $roles = Role::query()->get()->values()->all();
+        $this->deleteManyJointPermissionsForEntities($entities);
         $this->createManyJointPermissions($entities, $roles);
     }
 
     /**
      * Build the entity jointPermissions for a particular role.
-     * @param Role $role
      */
     public function buildJointPermissionForRole(Role $role)
     {
@@ -294,7 +255,7 @@ class PermissionService
         });
 
         // Chunk through all bookshelves
-        $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+        Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
             ->chunk(50, function ($shelves) use ($roles) {
                 $this->buildJointPermissionsForShelves($shelves, $roles);
             });
@@ -302,7 +263,6 @@ class PermissionService
 
     /**
      * Delete the entity jointPermissions attached to a particular role.
-     * @param Role $role
      */
     public function deleteJointPermissionsForRole(Role $role)
     {
@@ -311,6 +271,7 @@ class PermissionService
 
     /**
      * Delete all of the entity jointPermissions for a list of entities.
+     *
      * @param Role[] $roles
      */
     protected function deleteManyJointPermissionsForRoles($roles)
@@ -318,13 +279,15 @@ class PermissionService
         $roleIds = array_map(function ($role) {
             return $role->id;
         }, $roles);
-        $this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete();
+        JointPermission::query()->whereIn('role_id', $roleIds)->delete();
     }
 
     /**
      * Delete the entity jointPermissions for a particular entity.
+     *
      * @param Entity $entity
-     * @throws \Throwable
+     *
+     * @throws Throwable
      */
     public function deleteJointPermissionsForEntity(Entity $entity)
     {
@@ -333,17 +296,18 @@ class PermissionService
 
     /**
      * Delete all of the entity jointPermissions for a list of entities.
-     * @param \BookStack\Entities\Entity[] $entities
-     * @throws \Throwable
+     *
+     * @param Entity[] $entities
+     *
+     * @throws Throwable
      */
-    protected function deleteManyJointPermissionsForEntities($entities)
+    protected function deleteManyJointPermissionsForEntities(array $entities)
     {
         if (count($entities) === 0) {
             return;
         }
 
         $this->db->transaction(function () use ($entities) {
-
             foreach (array_chunk($entities, 1000) as $entityChunk) {
                 $query = $this->db->table('joint_permissions');
                 foreach ($entityChunk as $entity) {
@@ -358,19 +322,21 @@ class PermissionService
     }
 
     /**
-     * Create & Save entity jointPermissions for many entities and jointPermissions.
-     * @param Collection $entities
-     * @param array $roles
-     * @throws \Throwable
+     * Create & Save entity jointPermissions for many entities and roles.
+     *
+     * @param Entity[] $entities
+     * @param Role[]   $roles
+     *
+     * @throws Throwable
      */
-    protected function createManyJointPermissions($entities, $roles)
+    protected function createManyJointPermissions(array $entities, array $roles)
     {
         $this->readyEntityCache($entities);
         $jointPermissions = [];
 
         // Fetch Entity Permissions and create a mapping of entity restricted statuses
         $entityRestrictedMap = [];
-        $permissionFetch = $this->entityPermission->newQuery();
+        $permissionFetch = EntityPermission::query();
         foreach ($entities as $entity) {
             $entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
             $permissionFetch->orWhere(function ($query) use ($entity) {
@@ -411,35 +377,27 @@ class PermissionService
         });
     }
 
-
     /**
      * Get the actions related to an entity.
-     * @param \BookStack\Entities\Entity $entity
-     * @return array
      */
-    protected function getActions(Entity $entity)
+    protected function getActions(Entity $entity): array
     {
         $baseActions = ['view', 'update', 'delete'];
-        if ($entity->isA('chapter') || $entity->isA('book')) {
+        if ($entity instanceof Chapter || $entity instanceof Book) {
             $baseActions[] = 'page-create';
         }
-        if ($entity->isA('book')) {
+        if ($entity instanceof Book) {
             $baseActions[] = 'chapter-create';
         }
+
         return $baseActions;
     }
 
     /**
      * Create entity permission data for an entity and role
      * for a particular action.
-     * @param Entity $entity
-     * @param Role $role
-     * @param string $action
-     * @param array $permissionMap
-     * @param array $rolePermissionMap
-     * @return array
      */
-    protected function createJointPermissionData(Entity $entity, Role $role, $action, $permissionMap, $rolePermissionMap)
+    protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
     {
         $permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
         $roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
@@ -453,10 +411,11 @@ class PermissionService
 
         if ($entity->restricted) {
             $hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
+
             return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
         }
 
-        if ($entity->isA('book') || $entity->isA('bookshelf')) {
+        if ($entity instanceof Book || $entity instanceof Bookshelf) {
             return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
         }
 
@@ -466,7 +425,7 @@ class PermissionService
         $hasPermissiveAccessToParents = !$book->restricted;
 
         // For pages with a chapter, Check if explicit permissions are set on the Chapter
-        if ($entity->isA('page') && $entity->chapter_id !== 0 && $entity->chapter_id !== '0') {
+        if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
             $chapter = $this->getChapter($entity->chapter_id);
             $hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
             if ($chapter->restricted) {
@@ -485,29 +444,19 @@ class PermissionService
 
     /**
      * Check for an active restriction in an entity map.
-     * @param $entityMap
-     * @param Entity $entity
-     * @param Role $role
-     * @param $action
-     * @return bool
      */
-    protected function mapHasActiveRestriction($entityMap, Entity $entity, Role $role, $action)
+    protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
     {
         $key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
-        return isset($entityMap[$key]) ? $entityMap[$key] : false;
+
+        return $entityMap[$key] ?? false;
     }
 
     /**
      * Create an array of data with the information of an entity jointPermissions.
      * Used to build data for bulk insertion.
-     * @param \BookStack\Entities\Entity $entity
-     * @param Role $role
-     * @param $action
-     * @param $permissionAll
-     * @param $permissionOwn
-     * @return array
      */
-    protected function createJointPermissionDataArray(Entity $entity, Role $role, $action, $permissionAll, $permissionOwn)
+    protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
     {
         return [
             'role_id'            => $role->getRawAttribute('id'),
@@ -516,119 +465,91 @@ class PermissionService
             'action'             => $action,
             'has_permission'     => $permissionAll,
             'has_permission_own' => $permissionOwn,
-            'created_by'         => $entity->getRawAttribute('created_by')
+            'owned_by'           => $entity->getRawAttribute('owned_by'),
         ];
     }
 
     /**
      * Checks if an entity has a restriction set upon it.
-     * @param Ownable $ownable
-     * @param $permission
-     * @return bool
+     *
+     * @param HasCreatorAndUpdater|HasOwner $ownable
      */
-    public function checkOwnableUserAccess(Ownable $ownable, $permission)
+    public function checkOwnableUserAccess(Model $ownable, string $permission): bool
     {
         $explodedPermission = explode('-', $permission);
 
-        $baseQuery = $ownable->where('id', '=', $ownable->id);
+        $baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
         $action = end($explodedPermission);
-        $this->currentAction = $action;
+        $user = $this->currentUser();
 
         $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
 
         // Handle non entity specific jointPermissions
         if (in_array($explodedPermission[0], $nonJointPermissions)) {
-            $allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
-            $ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
-            $this->currentAction = 'view';
-            $isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->created_by;
-            return ($allPermission || ($isOwner && $ownPermission));
+            $allPermission = $user && $user->can($permission . '-all');
+            $ownPermission = $user && $user->can($permission . '-own');
+            $ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
+            $isOwner = $user && $user->id === $ownable->$ownerField;
+
+            return $allPermission || ($isOwner && $ownPermission);
         }
 
         // Handle abnormal create jointPermissions
         if ($action === 'create') {
-            $this->currentAction = $permission;
+            $action = $permission;
         }
 
-        $q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
+        $hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
         $this->clean();
-        return $q;
+
+        return $hasAccess;
     }
 
     /**
      * Checks if a user has the given permission for any items in the system.
      * Can be passed an entity instance to filter on a specific type.
-     * @param string $permission
-     * @param string $entityClass
-     * @return bool
      */
-    public function checkUserHasPermissionOnAnything(string $permission, string $entityClass = null)
+    public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
     {
         $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
         $userId = $this->currentUser()->id;
 
-        $permissionQuery = $this->db->table('joint_permissions')
+        $permissionQuery = JointPermission::query()
             ->where('action', '=', $permission)
             ->whereIn('role_id', $userRoleIds)
-            ->where(function ($query) use ($userId) {
-                $query->where('has_permission', '=', 1)
-                    ->orWhere(function ($query2) use ($userId) {
-                        $query2->where('has_permission_own', '=', 1)
-                            ->where('created_by', '=', $userId);
-                    });
+            ->where(function (Builder $query) use ($userId) {
+                $this->addJointHasPermissionCheck($query, $userId);
             });
 
         if (!is_null($entityClass)) {
-            $entityInstance = app()->make($entityClass);
+            $entityInstance = app($entityClass);
             $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
         }
 
         $hasPermission = $permissionQuery->count() > 0;
         $this->clean();
-        return $hasPermission;
-    }
 
-    /**
-     * Check if an entity has restrictions set on itself or its
-     * parent tree.
-     * @param \BookStack\Entities\Entity $entity
-     * @param $action
-     * @return bool|mixed
-     */
-    public function checkIfRestrictionsSet(Entity $entity, $action)
-    {
-        $this->currentAction = $action;
-        if ($entity->isA('page')) {
-            return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
-        } elseif ($entity->isA('chapter')) {
-            return $entity->restricted || $entity->book->restricted;
-        } elseif ($entity->isA('book')) {
-            return $entity->restricted;
-        }
+        return $hasPermission;
     }
 
     /**
      * The general query filter to remove all entities
      * that the current user does not have access to.
-     * @param $query
-     * @return mixed
-     */
-    protected function entityRestrictionQuery($query)
-    {
-        $q = $query->where(function ($parentQuery) {
-            $parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
-                $permissionQuery->whereIn('role_id', $this->getRoles())
-                    ->where('action', '=', $this->currentAction)
-                    ->where(function ($query) {
-                        $query->where('has_permission', '=', true)
-                            ->orWhere(function ($query) {
-                                $query->where('has_permission_own', '=', true)
-                                    ->where('created_by', '=', $this->currentUser()->id);
-                            });
+     */
+    protected function entityRestrictionQuery(Builder $query, string $action): Builder
+    {
+        $q = $query->where(function ($parentQuery) use ($action) {
+            $parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
+                $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
+                    ->where('action', '=', $action)
+                    ->where(function (Builder $query) {
+                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
                     });
             });
         });
+
         $this->clean();
+
         return $q;
     }
 
@@ -639,16 +560,13 @@ 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->getRoles())
+                $permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
                     ->where('action', '=', $ability)
                     ->where(function (Builder $query) {
-                        $query->where('has_permission', '=', true)
-                            ->orWhere(function (Builder $query) {
-                                $query->where('has_permission_own', '=', true)
-                                    ->where('created_by', '=', $this->currentUser()->id);
-                            });
+                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
                     });
             });
         });
@@ -658,104 +576,78 @@ class PermissionService
      * Extend the given page query to ensure draft items are not visible
      * unless created by the given user.
      */
-    public function enforceDraftVisiblityOnQuery(Builder $query): Builder
+    public function enforceDraftVisibilityOnQuery(Builder $query): Builder
     {
         return $query->where(function (Builder $query) {
             $query->where('draft', '=', false)
                 ->orWhere(function (Builder $query) {
                     $query->where('draft', '=', true)
-                        ->where('created_by', '=', $this->currentUser()->id);
+                        ->where('owned_by', '=', $this->currentUser()->id);
                 });
         });
     }
 
     /**
-     * Add restrictions for a generic entity
-     * @param string $entityType
-     * @param Builder|\BookStack\Entities\Entity $query
-     * @param string $action
-     * @return Builder
+     * Add restrictions for a generic entity.
      */
-    public function enforceEntityRestrictions($entityType, $query, $action = 'view')
+    public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
     {
-        if (strtolower($entityType) === 'page') {
+        if ($entity instanceof Page) {
             // Prevent drafts being visible to others.
-            $query = $query->where(function ($query) {
-                $query->where('draft', '=', false)
-                    ->orWhere(function ($query) {
-                        $query->where('draft', '=', true)
-                            ->where('created_by', '=', $this->currentUser()->id);
-                    });
-            });
+            $this->enforceDraftVisibilityOnQuery($query);
         }
 
-        $this->currentAction = $action;
-        return $this->entityRestrictionQuery($query);
+        return $this->entityRestrictionQuery($query, $action);
     }
 
     /**
      * Filter items that have entities set as a polymorphic relation.
-     * @param $query
-     * @param string $tableName
-     * @param string $entityIdColumn
-     * @param string $entityTypeColumn
-     * @param string $action
-     * @return QueryBuilder
+     *
+     * @param Builder|QueryBuilder $query
      */
-    public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
+    public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
     {
-
-        $this->currentAction = $action;
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
 
-        $q = $query->where(function ($query) use ($tableDetails) {
-            $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
-                $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', '=', $this->currentAction)
-                    ->whereIn('role_id', $this->getRoles())
-                    ->where(function ($query) {
-                        $query->where('has_permission', '=', true)->orWhere(function ($query) {
-                            $query->where('has_permission_own', '=', true)
-                                ->where('created_by', '=', $this->currentUser()->id);
-                        });
+        $q = $query->where(function ($query) use ($tableDetails, $action) {
+            $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('action', '=', $action)
+                    ->whereIn('role_id', $this->getCurrentUserRoles())
+                    ->where(function (QueryBuilder $query) {
+                        $this->addJointHasPermissionCheck($query, $this->currentUser()->id);
                     });
             });
         });
+
         $this->clean();
+
         return $q;
     }
 
     /**
      * Add conditions to a query to filter the selection to related entities
-     * where permissions are granted.
-     * @param $entityType
-     * @param $query
-     * @param $tableName
-     * @param $entityIdColumn
-     * @return mixed
+     * where view permissions are granted.
      */
-    public function filterRelatedEntity($entityType, $query, $tableName, $entityIdColumn)
+    public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
     {
-        $this->currentAction = 'view';
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
+        $morphClass = app($entityClass)->getMorphClass();
 
-        $pageMorphClass = $this->entityProvider->get($entityType)->getMorphClass();
-
-        $q = $query->where(function ($query) use ($tableDetails, $pageMorphClass) {
-            $query->where(function ($query) use (&$tableDetails, $pageMorphClass) {
-                $query->whereExists(function ($permissionQuery) use (&$tableDetails, $pageMorphClass) {
+        $q = $query->where(function ($query) use ($tableDetails, $morphClass) {
+            $query->where(function ($query) use (&$tableDetails, $morphClass) {
+                $query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
+                    /** @var Builder $permissionQuery */
                     $permissionQuery->select('id')->from('joint_permissions')
-                        ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
-                        ->where('entity_type', '=', $pageMorphClass)
-                        ->where('action', '=', $this->currentAction)
-                        ->whereIn('role_id', $this->getRoles())
-                        ->where(function ($query) {
-                            $query->where('has_permission', '=', true)->orWhere(function ($query) {
-                                $query->where('has_permission_own', '=', true)
-                                    ->where('created_by', '=', $this->currentUser()->id);
-                            });
+                        ->whereColumn('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);
                         });
                 });
             })->orWhere($tableDetails['entityIdColumn'], '=', 0);
@@ -767,12 +659,25 @@ class PermissionService
     }
 
     /**
-     * Get the current user
-     * @return \BookStack\Auth\User
+     * 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);
+        });
+    }
+
+    /**
+     * Get the current user.
      */
-    private function currentUser()
+    private function currentUser(): User
     {
-        if ($this->currentUserModel === false) {
+        if (is_null($this->currentUserModel)) {
             $this->currentUserModel = user();
         }
 
@@ -782,10 +687,9 @@ class PermissionService
     /**
      * Clean the cached user elements.
      */
-    private function clean()
+    private function clean(): void
     {
-        $this->currentUserModel = false;
-        $this->userRoles = false;
-        $this->isAdminUser = null;
+        $this->currentUserModel = null;
+        $this->userRoles = null;
     }
 }
index 56ef193015f7103a98bb440ec60498e5bf765c25..988146700f80e1760c6d667ac0fe29dc0de22542 100644 (file)
@@ -1,13 +1,16 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
 
-use BookStack\Auth\Permissions;
+namespace BookStack\Auth\Permissions;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\Role;
 use BookStack\Exceptions\PermissionsException;
-use Illuminate\Support\Str;
+use BookStack\Facades\Activity;
+use Exception;
+use Illuminate\Database\Eloquent\Collection;
 
 class PermissionsRepo
 {
-
     protected $permission;
     protected $role;
     protected $permissionService;
@@ -16,11 +19,8 @@ class PermissionsRepo
 
     /**
      * PermissionsRepo constructor.
-     * @param RolePermission $permission
-     * @param Role $role
-     * @param \BookStack\Auth\Permissions\PermissionService $permissionService
      */
-    public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
+    public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
     {
         $this->permission = $permission;
         $this->role = $role;
@@ -29,64 +29,53 @@ class PermissionsRepo
 
     /**
      * Get all the user roles from the system.
-     * @return \Illuminate\Database\Eloquent\Collection|static[]
      */
-    public function getAllRoles()
+    public function getAllRoles(): Collection
     {
         return $this->role->all();
     }
 
     /**
      * Get all the roles except for the provided one.
-     * @param Role $role
-     * @return mixed
      */
-    public function getAllRolesExcept(Role $role)
+    public function getAllRolesExcept(Role $role): Collection
     {
         return $this->role->where('id', '!=', $role->id)->get();
     }
 
     /**
      * Get a role via its ID.
-     * @param $id
-     * @return mixed
      */
-    public function getRoleById($id)
+    public function getRoleById($id): Role
     {
-        return $this->role->findOrFail($id);
+        return $this->role->newQuery()->findOrFail($id);
     }
 
     /**
      * Save a new role into the system.
-     * @param array $roleData
-     * @return Role
      */
-    public function saveNewRole($roleData)
+    public function saveNewRole(array $roleData): Role
     {
         $role = $this->role->newInstance($roleData);
-        $role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
-        // Prevent duplicate names
-        while ($this->role->where('name', '=', $role->name)->count() > 0) {
-            $role->name .= strtolower(Str::random(2));
-        }
+        $role->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;
     }
 
     /**
      * Updates an existing role.
      * Ensure Admin role always have core permissions.
-     * @param $roleId
-     * @param $roleData
-     * @throws PermissionsException
      */
-    public function updateRole($roleId, $roleData)
+    public function updateRole($roleId, array $roleData)
     {
-        $role = $this->role->findOrFail($roleId);
+        /** @var Role $role */
+        $role = $this->role->newQuery()->findOrFail($roleId);
 
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
         if ($role->system_name === 'admin') {
@@ -102,22 +91,27 @@ 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);
     }
 
     /**
      * Assign an list of permission names to an role.
-     * @param Role $role
-     * @param array $permissionNameArray
      */
-    public function assignRolePermissions(Role $role, $permissionNameArray = [])
+    protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
     {
         $permissions = [];
         $permissionNameArray = array_values($permissionNameArray);
-        if ($permissionNameArray && count($permissionNameArray) > 0) {
-            $permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
+
+        if ($permissionNameArray) {
+            $permissions = $this->permission->newQuery()
+                ->whereIn('name', $permissionNameArray)
+                ->pluck('id')
+                ->toArray();
         }
+
         $role->permissions()->sync($permissions);
     }
 
@@ -126,30 +120,32 @@ class PermissionsRepo
      * Check it's not an admin role or set as default before deleting.
      * If an migration Role ID is specified the users assign to the current role
      * will be added to the role of the specified id.
-     * @param $roleId
-     * @param $migrateRoleId
+     *
      * @throws PermissionsException
+     * @throws Exception
      */
     public function deleteRole($roleId, $migrateRoleId)
     {
-        $role = $this->role->findOrFail($roleId);
+        /** @var Role $role */
+        $role = $this->role->newQuery()->findOrFail($roleId);
 
         // Prevent deleting admin role or default registration role.
         if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
             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'));
         }
 
         if ($migrateRoleId) {
-            $newRole = $this->role->find($migrateRoleId);
+            $newRole = $this->role->newQuery()->find($migrateRoleId);
             if ($newRole) {
-                $users = $role->users->pluck('id')->toArray();
+                $users = $role->users()->pluck('id')->toArray();
                 $newRole->users()->sync($users);
             }
         }
 
         $this->permissionService->deleteJointPermissionsForRole($role);
+        Activity::add(ActivityType::ROLE_DELETE, $role);
         $role->delete();
     }
 }
index 8b07b3073bf9add0eb1f4a216844c69e05235832..0a0e6ff17f531a3d16b3fab542716a84487dbc94 100644 (file)
@@ -1,8 +1,13 @@
-<?php namespace BookStack\Auth\Permissions;
+<?php
+
+namespace BookStack\Auth\Permissions;
 
 use BookStack\Auth\Role;
 use BookStack\Model;
 
+/**
+ * @property int $id
+ */
 class RolePermission extends Model
 {
     /**
@@ -15,7 +20,9 @@ class RolePermission extends Model
 
     /**
      * Get the permission object by name.
+     *
      * @param $name
+     *
      * @return mixed
      */
     public static function getByName($name)
index df9b1cea93faa7d413800e966248fd6f99eb62b1..46921caeb1a2adebb9255088c39dd1c13489497b 100644 (file)
@@ -1,34 +1,42 @@
-<?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\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 
 /**
- * Class Role
- * @property string $display_name
- * @property string $description
- * @property string $external_auth_id
- * @package BookStack\Auth
+ * 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
+class Role extends Model implements Loggable
 {
-
     protected $fillable = ['display_name', 'description', 'external_auth_id'];
 
     /**
      * The roles that belong to the role.
      */
-    public function users()
+    public function users(): BelongsToMany
     {
         return $this->belongsToMany(User::class)->orderBy('name', 'asc');
     }
 
     /**
      * Get all related JointPermissions.
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
      */
-    public function jointPermissions()
+    public function jointPermissions(): HasMany
     {
         return $this->hasMany(JointPermission::class);
     }
@@ -36,17 +44,15 @@ class Role extends Model
     /**
      * The RolePermissions that belong to the role.
      */
-    public function permissions()
+    public function permissions(): BelongsToMany
     {
         return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
     }
 
     /**
      * Check if this role has a permission.
-     * @param $permissionName
-     * @return bool
      */
-    public function hasPermission($permissionName)
+    public function hasPermission(string $permissionName): bool
     {
         $permissions = $this->getRelationValue('permissions');
         foreach ($permissions as $permission) {
@@ -54,12 +60,12 @@ class Role extends Model
                 return true;
             }
         }
+
         return false;
     }
 
     /**
      * Add a permission to this role.
-     * @param RolePermission $permission
      */
     public function attachPermission(RolePermission $permission)
     {
@@ -68,7 +74,6 @@ class Role extends Model
 
     /**
      * Detach a single permission from this role.
-     * @param RolePermission $permission
      */
     public function detachPermission(RolePermission $permission)
     {
@@ -76,40 +81,45 @@ class Role extends Model
     }
 
     /**
-     * Get the role object for the specified role.
-     * @param $roleName
-     * @return Role
+     * Get the role of the specified display name.
      */
-    public static function getRole($roleName)
+    public static function getRole(string $displayName): ?Role
     {
-        return static::query()->where('name', '=', $roleName)->first();
+        return static::query()->where('display_name', '=', $displayName)->first();
     }
 
     /**
      * Get the role object for the specified system role.
-     * @param $roleName
-     * @return Role
      */
-    public static function getSystemRole($roleName)
+    public static function getSystemRole(string $systemName): ?Role
     {
-        return static::query()->where('system_name', '=', $roleName)->first();
+        return static::query()->where('system_name', '=', $systemName)->first();
     }
 
     /**
-     * Get all visible roles
-     * @return mixed
+     * Get all visible roles.
      */
-    public static function visible()
+    public static function visible(): Collection
     {
         return static::query()->where('hidden', '=', false)->orderBy('name')->get();
     }
 
     /**
      * Get the roles that can be restricted.
-     * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
      */
-    public static function restrictable()
+    public static function restrictable(): Collection
+    {
+        return static::query()
+            ->where('system_name', '!=', 'admin')
+            ->orderBy('display_name', 'asc')
+            ->get();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function logDescriptor(): string
     {
-        return static::query()->where('system_name', '!=', 'admin')->get();
+        return "({$this->id}) {$this->display_name}";
     }
 }
index 804dbe6292973c16b7dc068ad00dd353c33fa48d..f076ecdd440577e1f2dae85c9b676d649021416e 100644 (file)
@@ -1,14 +1,30 @@
-<?php namespace BookStack\Auth;
+<?php
 
+namespace BookStack\Auth;
+
+use BookStack\Interfaces\Loggable;
 use BookStack\Model;
 
-class SocialAccount extends Model
+/**
+ * Class SocialAccount.
+ *
+ * @property string $driver
+ * @property User   $user
+ */
+class SocialAccount extends Model implements Loggable
 {
-
     protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
 
     public function user()
     {
         return $this->belongsTo(User::class);
     }
+
+    /**
+     * @inheritDoc
+     */
+    public function logDescriptor(): string
+    {
+        return "{$this->driver}; {$this->user->logDescriptor()}";
+    }
 }
index 40718beb6bfdccc1f7fb53770c7600fffd43b2ed..0a6849fe008323aca74f08cf108441a78b59a0c6 100644 (file)
@@ -1,50 +1,71 @@
-<?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;
 use BookStack\Model;
 use BookStack\Notifications\ResetPassword;
 use BookStack\Uploads\Image;
 use Carbon\Carbon;
+use Exception;
 use Illuminate\Auth\Authenticatable;
 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\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Notifications\Notifiable;
+use Illuminate\Support\Collection;
 
 /**
- * Class User
- * @package BookStack\Auth
- * @property string $id
- * @property string $name
- * @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 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
+ * @property Collection $roles
+ * @property Collection $mfaValues
  */
-class User extends Model implements AuthenticatableContract, CanResetPasswordContract
+class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
 {
-    use Authenticatable, CanResetPassword, Notifiable;
+    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'];
 
+    protected $casts = ['last_activity_at' => 'datetime'];
+
     /**
      * The attributes excluded from the model's JSON form.
+     *
      * @var array
      */
     protected $hidden = [
@@ -54,69 +75,68 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * This holds the user's permissions when loaded.
-     * @var array
+     *
+     * @var ?Collection
      */
     protected $permissions;
 
     /**
      * This holds the default user when loaded.
+     *
      * @var null|User
      */
     protected static $defaultUser = null;
 
     /**
      * Returns the default public user.
-     * @return User
      */
-    public static function getDefault()
+    public static function getDefault(): User
     {
         if (!is_null(static::$defaultUser)) {
             return static::$defaultUser;
         }
-        
-        static::$defaultUser = static::where('system_name', '=', 'public')->first();
+
+        static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
+
         return static::$defaultUser;
     }
 
     /**
      * Check if the user is the default public user.
-     * @return bool
      */
-    public function isDefault()
+    public function isDefault(): bool
     {
         return $this->system_name === 'public';
     }
 
     /**
      * The roles that belong to the user.
+     *
      * @return BelongsToMany
      */
     public function roles()
     {
         if ($this->id === 0) {
-            return ;
+            return;
         }
+
         return $this->belongsToMany(Role::class);
     }
 
     /**
      * Check if the user has a role.
-     * @param $role
-     * @return mixed
      */
-    public function hasRole($role)
+    public function hasRole($roleId): bool
     {
-        return $this->roles->pluck('name')->contains($role);
+        return $this->roles->pluck('id')->contains($roleId);
     }
 
     /**
      * Check if the user has a role.
-     * @param $role
-     * @return mixed
      */
-    public function hasSystemRole($role)
+    public function hasSystemRole(string $roleSystemName): bool
     {
-        return $this->roles->pluck('system_name')->contains($role);
+        return $this->roles->pluck('system_name')->contains($roleSystemName);
     }
 
     /**
@@ -130,40 +150,48 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
         }
     }
 
+    /**
+     * Check if the user has a particular permission.
+     */
+    public function can(string $permissionName): bool
+    {
+        if ($this->email === 'guest') {
+            return false;
+        }
+
+        return $this->permissions()->contains($permissionName);
+    }
+
     /**
      * Get all permissions belonging to a the current user.
-     * @param bool $cache
-     * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
      */
-    public function permissions($cache = true)
+    protected function permissions(): Collection
     {
-        if (isset($this->permissions) && $cache) {
+        if (isset($this->permissions)) {
             return $this->permissions;
         }
-        $this->load('roles.permissions');
-        $permissions = $this->roles->map(function ($role) {
-            return $role->permissions;
-        })->flatten()->unique();
-        $this->permissions = $permissions;
-        return $permissions;
+
+        $this->permissions = $this->newQuery()->getConnection()->table('role_user', 'ru')
+            ->select('role_permissions.name as name')->distinct()
+            ->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;
     }
 
     /**
-     * Check if the user has a particular permission.
-     * @param $permissionName
-     * @return bool
+     * Clear any cached permissions on this instance.
      */
-    public function can($permissionName)
+    public function clearPermissionCache()
     {
-        if ($this->email === 'guest') {
-            return false;
-        }
-        return $this->permissions()->pluck('name')->contains($permissionName);
+        $this->permissions = null;
     }
 
     /**
      * Attach a role to this user.
-     * @param Role $role
      */
     public function attachRole(Role $role)
     {
@@ -172,9 +200,8 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * Get the social account associated with this user.
-     * @return \Illuminate\Database\Eloquent\Relations\HasMany
      */
-    public function socialAccounts()
+    public function socialAccounts(): HasMany
     {
         return $this->hasMany(SocialAccount::class);
     }
@@ -182,7 +209,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)
@@ -195,11 +224,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * Returns the user's avatar,
-     * @param int $size
-     * @return string
+     * Returns a URL to the user's avatar.
      */
-    public function getAvatar($size = 50)
+    public function getAvatar(int $size = 50): string
     {
         $default = url('/user_avatar.png');
         $imageId = $this->image_id;
@@ -209,17 +236,17 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
         try {
             $avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
-        } catch (\Exception $err) {
+        } catch (Exception $err) {
             $avatar = $default;
         }
+
         return $avatar;
     }
 
     /**
      * Get the avatar for the user.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
      */
-    public function avatar()
+    public function avatar(): BelongsTo
     {
         return $this->belongsTo(Image::class, 'image_id');
     }
@@ -232,12 +259,42 @@ 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.
+     */
+    public function scopeWithLastActivityAt(Builder $query)
+    {
+        $query->addSelect(['activities.created_at as last_activity_at'])
+            ->leftJoinSub(function (\Illuminate\Database\Query\Builder $query) {
+                $query->from('activities')->select('user_id')
+                    ->selectRaw('max(created_at) as created_at')
+                    ->groupBy('user_id');
+            }, 'activities', 'users.id', '=', 'activities.user_id');
+    }
+
     /**
      * Get the url for editing this user.
      */
     public function getEditUrl(string $path = ''): string
     {
         $uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
+
         return url(rtrim($uri, '/'));
     }
 
@@ -246,15 +303,13 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
      */
     public function getProfileUrl(): string
     {
-        return url('/user/' . $this->id);
+        return url('/user/' . $this->slug);
     }
 
     /**
      * Get a shortened version of the user's name.
-     * @param int $chars
-     * @return string
      */
-    public function getShortName($chars = 8)
+    public function getShortName(int $chars = 8): string
     {
         if (mb_strlen($this->name) <= $chars) {
             return $this->name;
@@ -270,11 +325,31 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 
     /**
      * Send the password reset notification.
-     * @param  string  $token
+     *
+     * @param string $token
+     *
      * @return void
      */
     public function sendPasswordResetNotification($token)
     {
         $this->notify(new ResetPassword($token));
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function logDescriptor(): string
+    {
+        return "({$this->id}) {$this->name}";
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function refreshSlug(): string
+    {
+        $this->slug = app(SlugGenerator::class)->generate($this);
+
+        return $this->slug;
+    }
 }
index cfa7bfce1f1c836ffde1beddd0f58e66d07bd99a..6d48f12402060edbbe56f5660301dc1183ca5dcc 100644 (file)
@@ -1,31 +1,32 @@
-<?php namespace BookStack\Auth;
+<?php
+
+namespace BookStack\Auth;
 
 use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+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 BookStack\Exceptions\NotFoundException;
 use BookStack\Exceptions\UserUpdateException;
-use BookStack\Uploads\Image;
+use BookStack\Uploads\UserAvatars;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
-use Images;
-use Log;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\Log;
 
 class UserRepo
 {
-
-    protected $user;
-    protected $role;
+    protected $userAvatar;
 
     /**
      * UserRepo constructor.
      */
-    public function __construct(User $user, Role $role)
+    public function __construct(UserAvatars $userAvatar)
     {
-        $this->user = $user;
-        $this->role = $role;
+        $this->userAvatar = $userAvatar;
     }
 
     /**
@@ -33,36 +34,45 @@ class UserRepo
      */
     public function getByEmail(string $email): ?User
     {
-        return $this->user->where('email', '=', $email)->first();
+        return User::query()->where('email', '=', $email)->first();
+    }
+
+    /**
+     * Get a user by their ID.
+     */
+    public function getById(int $id): User
+    {
+        return User::query()->findOrFail($id);
     }
 
     /**
-     * @param int $id
-     * @return User
+     * Get a user by their slug.
      */
-    public function getById($id)
+    public function getBySlug(string $slug): User
     {
-        return $this->user->newQuery()->findOrFail($id);
+        return User::query()->where('slug', '=', $slug)->firstOrFail();
     }
 
     /**
      * Get all the users with their permissions.
-     * @return Builder|static
      */
-    public function getAllUsers()
+    public function getAllUsers(): Collection
     {
-        return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
+        return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
     }
 
     /**
      * Get all the users with their permissions in a paginated format.
-     * @param int $count
-     * @param $sortData
-     * @return Builder|static
      */
-    public function getAllUsersPaginatedAndSorted($count, $sortData)
+    public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
     {
-        $query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
+        $sort = $sortData['sort'];
+
+        $query = User::query()->select(['*'])
+            ->withLastActivityAt()
+            ->with(['roles', 'avatar'])
+            ->withCount('mfaValues')
+            ->orderBy($sort, $sortData['order']);
 
         if ($sortData['search']) {
             $term = '%' . $sortData['search'] . '%';
@@ -75,7 +85,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
@@ -89,14 +99,13 @@ class UserRepo
 
     /**
      * Assign a user to a system-level role.
-     * @param User $user
-     * @param $systemRoleName
+     *
      * @throws NotFoundException
      */
-    public function attachSystemRole(User $user, $systemRoleName)
+    public function attachSystemRole(User $user, string $systemRoleName)
     {
-        $role = $this->role->newQuery()->where('system_name', '=', $systemRoleName)->first();
-        if ($role === null) {
+        $role = Role::getSystemRole($systemRoleName);
+        if (is_null($role)) {
             throw new NotFoundException("Role '{$systemRoleName}' not found");
         }
         $user->attachRole($role);
@@ -104,26 +113,24 @@ class UserRepo
 
     /**
      * Checks if the give user is the only admin.
-     * @param User $user
-     * @return bool
      */
-    public function isOnlyAdmin(User $user)
+    public function isOnlyAdmin(User $user): bool
     {
         if (!$user->hasSystemRole('admin')) {
             return false;
         }
 
-        $adminRole = $this->role->getSystemRole('admin');
-        if ($adminRole->users->count() > 1) {
+        $adminRole = Role::getSystemRole('admin');
+        if ($adminRole->users()->count() > 1) {
             return false;
         }
+
         return true;
     }
 
     /**
      * Set the assigned user roles via an array of role IDs.
-     * @param User $user
-     * @param array $roles
+     *
      * @throws UserUpdateException
      */
     public function setUserRoles(User $user, array $roles)
@@ -138,14 +145,11 @@ class UserRepo
     /**
      * Check if the given user is the last admin and their new roles no longer
      * contains the admin role.
-     * @param User $user
-     * @param array $newRoles
-     * @return bool
      */
-    protected function demotingLastAdmin(User $user, array $newRoles) : bool
+    protected function demotingLastAdmin(User $user, array $newRoles): bool
     {
         if ($this->isOnlyAdmin($user)) {
-            $adminRole = $this->role->getSystemRole('admin');
+            $adminRole = Role::getSystemRole('admin');
             if (!in_array(strval($adminRole->id), $newRoles)) {
                 return true;
             }
@@ -159,41 +163,62 @@ class UserRepo
      */
     public function create(array $data, bool $emailConfirmed = false): User
     {
-        return $this->user->forceCreate([
-            'name'     => $data['name'],
-            'email'    => $data['email'],
-            'password' => bcrypt($data['password']),
-            'email_confirmed' => $emailConfirmed,
+        $details = [
+            'name'             => $data['name'],
+            'email'            => $data['email'],
+            'password'         => bcrypt($data['password']),
+            'email_confirmed'  => $emailConfirmed,
             'external_auth_id' => $data['external_auth_id'] ?? '',
-        ]);
+        ];
+
+        $user = new User();
+        $user->forceFill($details);
+        $user->refreshSlug();
+        $user->save();
+
+        return $user;
     }
 
     /**
      * Remove the given user from storage, Delete all related content.
-     * @param User $user
+     *
      * @throws Exception
      */
-    public function destroy(User $user)
+    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::where('type', '=', 'user')->where('uploaded_to', '=', $user->id)->get();
-        foreach ($profileImages as $image) {
-            Images::destroy($image);
+        $this->userAvatar->destroyAllForUser($user);
+
+        if (!empty($newOwnerId)) {
+            $newOwner = User::query()->find($newOwnerId);
+            if (!is_null($newOwner)) {
+                $this->migrateOwnership($user, $newOwner);
+            }
+        }
+    }
+
+    /**
+     * Migrate ownership of items in the system from one user to another.
+     */
+    protected function migrateOwnership(User $fromUser, User $toUser)
+    {
+        $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.
-     * @param User $user
-     * @param int $count
-     * @param int $page
-     * @return array
      */
-    public function getActivity(User $user, $count = 20, $page = 0)
+    public function getActivity(User $user, int $count = 20, int $page = 0): array
     {
         return Activity::userActivity($user, $count, $page);
     }
@@ -224,43 +249,33 @@ 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(),
         ];
     }
 
     /**
      * Get the roles in the system that are assignable to a user.
-     * @return mixed
      */
-    public function getAllRoles()
+    public function getAllRoles(): Collection
     {
-        return $this->role->newQuery()->orderBy('name', 'asc')->get();
+        return Role::query()->orderBy('display_name', 'asc')->get();
     }
 
     /**
      * Get an avatar image for a user and set it as their avatar.
      * Returns early if avatars disabled or not set in config.
-     * @param User $user
-     * @return bool
      */
-    public function downloadAndAssignUserAvatar(User $user)
+    public function downloadAndAssignUserAvatar(User $user): void
     {
-        if (!Images::avatarFetchEnabled()) {
-            return false;
-        }
-
         try {
-            $avatar = Images::saveUserAvatar($user);
-            $user->avatar()->associate($avatar);
-            $user->save();
-            return true;
+            $this->userAvatar->fetchAndAssignToUser($user);
         } catch (Exception $e) {
             Log::error('Failed to save user avatar image');
-            return false;
         }
     }
 }
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),
 
 ];
index a9956edd842a1afc6d9fefe83a5a1ccab43976e9..120644aede9b3e048e38120719ff10a4a92549eb 100755 (executable)
@@ -19,23 +19,28 @@ return [
     // private configuration variables so should remain disabled in public.
     'debug' => env('APP_DEBUG', false),
 
-    // Set the default view type for various lists. Can be overridden by user preferences.
-    // These will be used for public viewers and users that have not set a preference.
-    'views' => [
-        'books' => env('APP_VIEWS_BOOKS', 'list'),
-        'bookshelves' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
-    ],
-
     // The number of revisions to keep in the database.
     // Once this limit is reached older revisions will be deleted.
     // If set to false then a limit will not be enforced.
     'revision_limit' => env('REVISION_LIMIT', 50),
 
+    // The number of days that content will remain in the recycle bin before
+    // being considered for auto-removal. It is not a guarantee that content will
+    // be removed after this time.
+    // Set to 0 for no recycle bin functionality.
+    // Set to -1 for unlimited recycle bin lifetime.
+    'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
+
     // 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.
@@ -45,6 +50,10 @@ return [
     // and used by BookStack in URL generation.
     'url' => env('APP_URL', '') === 'http://bookstack.dev' ? '' : env('APP_URL', ''),
 
+    // A list of hosts that BookStack can be iframed within.
+    // Space separated if multiple. BookStack host domain is auto-inferred.
+    'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
+
     // Application timezone for back-end date functions.
     'timezone' => env('APP_TIMEZONE', 'UTC'),
 
@@ -52,7 +61,7 @@ return [
     'locale' => env('APP_LANG', 'en'),
 
     // Locales available
-    'locales' => ['en', 'ar', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl',  'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
+    'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', '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',
@@ -111,12 +120,14 @@ return [
         BookStack\Providers\TranslationServiceProvider::class,
 
         // BookStack custom service providers
+        BookStack\Providers\ThemeServiceProvider::class,
         BookStack\Providers\AuthServiceProvider::class,
         BookStack\Providers\AppServiceProvider::class,
         BookStack\Providers\BroadcastServiceProvider::class,
         BookStack\Providers\EventServiceProvider::class,
         BookStack\Providers\RouteServiceProvider::class,
         BookStack\Providers\CustomFacadeProvider::class,
+        BookStack\Providers\CustomValidationServiceProvider::class,
     ],
 
     /*
@@ -134,55 +145,52 @@ return [
     '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,
+        '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,
         '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,
+        '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,
 
         // 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,
-        'Setting'  => BookStack\Facades\Setting::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,
     ],
 
     // Proxy configuration
index a1824bc78292858468b54cb090b775538c3d2ef7..5b39bafed051d4aba1fe682ae7fbd4590b2408f8 100644 (file)
@@ -18,7 +18,7 @@ return [
     // 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',
     ],
 
@@ -29,15 +29,15 @@ return [
     // Supported drivers: "session", "api-token", "ldap-session"
     'guards' => [
         'standard' => [
-            'driver' => 'session',
+            'driver'   => 'session',
             'provider' => 'users',
         ],
         'ldap' => [
-            'driver' => 'ldap-session',
+            'driver'   => 'ldap-session',
             'provider' => 'external',
         ],
         'saml2' => [
-            'driver' => 'saml2-session',
+            'driver'   => 'saml2-session',
             'provider' => 'external',
         ],
         'openid' => [
@@ -56,11 +56,11 @@ 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,
         ],
     ],
 
@@ -71,9 +71,9 @@ return [
     'passwords' => [
         'users' => [
             'provider' => 'users',
-            'email' => 'emails.password',
-            'table' => 'password_resets',
-            'expire' => 60,
+            'email'    => 'emails.password',
+            'table'    => 'password_resets',
+            'expire'   => 60,
         ],
     ],
 
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..f9b7ed1d20ecb9ff295f6bc94ce7657583b778ee 100644 (file)
@@ -42,8 +42,8 @@ return [
         ],
 
         'database' => [
-            'driver' => 'database',
-            'table'  => 'cache',
+            'driver'     => 'database',
+            'table'      => 'cache',
             'connection' => null,
         ],
 
@@ -58,7 +58,7 @@ return [
         ],
 
         'redis' => [
-            'driver' => 'redis',
+            'driver'     => 'redis',
             'connection' => 'default',
         ],
 
index ed654ffb9172b4789a62c922d971adb8f550976d..0c696609526fa5fc02fca74e975f076f87ee6884 100644 (file)
@@ -59,37 +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'),
-            '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,
         ],
 
     ],
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..cf07312e8a25854b4387376fbe015789a4773602 100644 (file)
 
 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 +37,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 +56,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 +70,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(base_path()),
 
         /**
          * Whether to use Unicode fonts or not.
@@ -82,20 +81,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 +115,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 +141,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 +150,19 @@ return [
          *
          * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
          */
-        "DOMPDF_DEFAULT_PAPER_SIZE" => "a4",
+        'default_paper_size' => '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 +194,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 +208,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 +237,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 bd7d28300abae17112857ead07d3d000c4fd823b..95fc35c2a8c0b702ee2cc8743a39425f9416b369 100644 (file)
@@ -34,7 +34,7 @@ return [
 
         'local' => [
             'driver' => 'local',
-            'root' => public_path(),
+            'root'   => public_path(),
         ],
 
         'local_secure' => [
@@ -42,33 +42,16 @@ return [
             'root'   => storage_path(),
         ],
 
-        'ftp' => [
-            'driver'   => 'ftp',
-            'host'     => 'ftp.example.com',
-            'username' => 'your-username',
-            'password' => 'your-password',
-        ],
-
         '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,
         ],
 
-        'rackspace' => [
-            'driver'    => 'rackspace',
-            'username'  => 'your-username',
-            'key'       => 'your-key',
-            'container' => 'your-container',
-            'endpoint'  => 'https://identity.api.rackspacecloud.com/v2.0/',
-            'region'    => 'IAD',
-            'url_type'  => 'publicURL',
-        ],
-
     ],
 
 ];
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 375e84083f9cb647468521cb2a850397684b645e..220aa0607044482d1b213d6c4f3ec34a911596fe 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use Monolog\Formatter\LineFormatter;
+use Monolog\Handler\ErrorLogHandler;
 use Monolog\Handler\NullHandler;
 use Monolog\Handler\StreamHandler;
 
@@ -28,53 +30,66 @@ 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,
+            'path'   => storage_path('logs/laravel.log'),
+            'level'  => 'debug',
+            'days'   => 7,
         ],
 
         'slack' => [
-            'driver' => 'slack',
-            'url' => env('LOG_SLACK_WEBHOOK_URL'),
+            'driver'   => 'slack',
+            'url'      => env('LOG_SLACK_WEBHOOK_URL'),
             'username' => 'Laravel Log',
-            'emoji' => ':boom:',
-            'level' => 'critical',
+            'emoji'    => ':boom:',
+            'level'    => 'critical',
         ],
 
         'stderr' => [
-            'driver' => 'monolog',
+            'driver'  => 'monolog',
             '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,
+            'formatter_with' => [
+                'format' => '%message%',
+            ],
         ],
 
         'null' => [
-            'driver' => 'monolog',
+            'driver'  => 'monolog',
             'handler' => NullHandler::class,
         ],
 
@@ -86,4 +101,11 @@ return [
         ],
     ],
 
+    // Failed Login Message
+    // Allows a configurable message to be logged when a login request fails.
+    'failed_login' => [
+        'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
+        'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
+    ],
+
 ];
index a91bdf23797ef182325da8f49d621d541f1bc8d2..34b28fe2a6b87b20059b7fc0aecfe1fff3f1a1e0 100644 (file)
@@ -11,7 +11,7 @@
 return [
 
     // Mail driver to use.
-    // Options: smtp, mail, sendmail, log
+    // Options: smtp, sendmail, log, array
     'driver' => env('MAIL_DRIVER', 'smtp'),
 
     // SMTP host address
@@ -23,7 +23,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
index 46f6962c5f327f4fd644f029d3159eece4f6701a..0c79fcdd20ce3a51e876cf0cc30f46a2f4b76482 100644 (file)
@@ -17,24 +17,23 @@ return [
     // Queue connection configuration
     'connections' => [
 
-
         'sync' => [
             'driver' => 'sync',
         ],
 
         'database' => [
-            'driver' => 'database',
-            'table' => 'jobs',
-            'queue' => 'default',
+            'driver'      => 'database',
+            'table'       => 'jobs',
+            'queue'       => 'default',
             'retry_after' => 90,
         ],
 
         'redis' => [
-            'driver' => 'redis',
-            'connection' => 'default',
-            'queue' => env('REDIS_QUEUE', 'default'),
+            'driver'      => 'redis',
+            'connection'  => 'default',
+            'queue'       => env('REDIS_QUEUE', 'default'),
             'retry_after' => 90,
-            'block_for' => null,
+            'block_for'   => null,
         ],
 
     ],
index 5f2c1395b836aacedd36496d153a0ea52f412165..fe311057c1b1a3a14507334f2f422747de9e573c 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+$SAML2_IDP_AUTHNCONTEXT = env('SAML2_IDP_AUTHNCONTEXT', true);
+
 return [
 
     // Display name, shown to users, for SAML2 option
@@ -29,7 +31,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
@@ -79,7 +80,7 @@ return [
             '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' => '',
+            'x509cert'   => '',
             'privateKey' => '',
         ],
         // Identity Provider Data that we want connect with our SP
@@ -101,7 +102,7 @@ return [
                 'url' => env('SAML2_IDP_SLO', null),
                 // URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
                 // if not set, url for the SLO Request will be used
-                'responseUrl' => '',
+                'responseUrl' => null,
                 // SAML protocol binding to be used when returning the <Response>
                 // message.  Onelogin Toolkit supports for this endpoint the
                 // HTTP-Redirect binding only
@@ -139,6 +140,14 @@ 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,
+        ],
     ],
 
 ];
index fcde621d2b5296bd9239085adc6409d7d39784a3..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,43 +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),
+        '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 37f1627bb5f5c151ae01748cdc06b8f5c7e7eeb2..4bbb789010ff341a0cf6d6b505201412a5b76819 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use Illuminate\Support\Str;
+
 /**
  * Session configuration options.
  *
@@ -57,7 +59,7 @@ return [
     // The session cookie path determines the path for which the cookie will
     // be regarded as available. Typically, this will be the root path of
     // your application but you are free to change this when necessary.
-    'path' => '/',
+    'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''),
 
     // Session Cookie Domain
     // Here you may change the domain of the cookie used to identify a session
@@ -69,7 +71,8 @@ return [
     // By setting this option to true, session cookies will only be sent back
     // to the server if the browser has a HTTPS connection. This will keep
     // the cookie from being sent to you if it can not be done securely.
-    'secure' => env('SESSION_SECURE_COOKIE', false),
+    'secure' => env('SESSION_SECURE_COOKIE', null)
+        ?? Str::startsWith(env('APP_URL'), 'https:'),
 
     // HTTP Access Only
     // Setting this value to true will prevent JavaScript from accessing the
@@ -80,6 +83,6 @@ return [
     // This option determines how your cookies behave when cross-site requests
     // take place, and can be used to mitigate CSRF attacks. By default, we
     // do not enable this as other CSRF protection services are in place.
-    // Options: lax, strict
-    'same_site' => null,
+    // Options: lax, strict, none
+    'same_site' => 'lax',
 ];
index d84c0c2641397700da7b551414122c66e88fa24b..cb6082c528619ba420d8e1d657704d9350038b0d 100644 (file)
@@ -24,4 +24,12 @@ return [
     'app-custom-head'      => false,
     'registration-enabled' => false,
 
+    // User-level default settings
+    'user' => [
+        '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'),
+    ],
+
 ];
index f347eda23349264d1b3d4118afadbedde0a5990a..0f012bef6cfd52004e8ff233b9169d22ec8f5bff 100644 (file)
@@ -14,7 +14,7 @@ return [
         'binary'  => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
         'timeout' => false,
         'options' => [
-            'outline' => true
+            'outline' => true,
         ],
         'env'     => [],
     ],
index f2e2d9fbd4465a3544e96e54c4649fb54fe81e05..7221501979a529861e8e505ce66f9f79d7771160 100644 (file)
@@ -14,8 +14,8 @@ class CleanupImages extends Command
      * @var string
      */
     protected $signature = 'bookstack:cleanup-images
-                            {--a|all : Include images that are used in page revisions}
-                            {--f|force : Actually run the deletions}
+                            {--a|all : Also delete images that are only used in old revisions}
+                            {--f|force : Actually run the deletions, Defaults to a dry-run}
                             ';
 
     /**
@@ -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 15f1fcc0a7138f057d3894c727cb62774a0ef37b..681a7564b282e3e6d279c1ffa2b9ddfbce96488e 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Entities\PageRevision;
+use BookStack\Entities\Models\PageRevision;
 use Illuminate\Console\Command;
 
 class ClearRevisions extends Command
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 6b5d35a476798a67d2bf16a42d2225b67363d632..32adf06839c82d8a78acb8f8ab6e02c621c3d298 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Repos\BookshelfRepo;
 use Illuminate\Console\Command;
 
@@ -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 e67da871763f8b9ef379c8687961ec2612d33bc3..a0fb8f31506899d83a2af899efb3d3613300ee41 100644 (file)
@@ -28,8 +28,6 @@ class CreateAdmin extends Command
 
     /**
      * Create a new command instance.
-     *
-     * @param UserRepo $userRepo
      */
     public function __construct(UserRepo $userRepo)
     {
@@ -40,8 +38,9 @@ class CreateAdmin extends Command
     /**
      * Execute the console command.
      *
-     * @return mixed
      * @throws \BookStack\Exceptions\NotFoundException
+     *
+     * @return mixed
      */
     public function handle()
     {
@@ -73,7 +72,6 @@ class CreateAdmin extends Command
             return $this->error('Invalid password provided, Must be at least 5 characters');
         }
 
-
         $user = $this->userRepo->create(['email' => $email, 'name' => $name, 'password' => $password]);
         $this->userRepo->attachSystemRole($user, 'admin');
         $this->userRepo->downloadAndAssignUserAvatar($user);
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 dc57f2cea764b3a8517de9453ae4ee05fd86d9a6..50e81a2b8e2578edeb8b2d2208f158def6a0256f 100644 (file)
@@ -2,9 +2,9 @@
 
 namespace BookStack\Console\Commands;
 
-use BookStack\Entities\SearchService;
-use DB;
+use BookStack\Entities\Tools\SearchIndex;
 use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
 
 class RegenerateSearch extends Command
 {
@@ -22,17 +22,15 @@ class RegenerateSearch extends Command
      */
     protected $description = 'Re-index all content for searching';
 
-    protected $searchService;
+    protected $searchIndex;
 
     /**
      * Create a new command instance.
-     *
-     * @param SearchService $searchService
      */
-    public function __construct(SearchService $searchService)
+    public function __construct(SearchIndex $searchIndex)
     {
         parent::__construct();
-        $this->searchService = $searchService;
+        $this->searchIndex = $searchIndex;
     }
 
     /**
@@ -45,10 +43,9 @@ class RegenerateSearch extends Command
         $connection = DB::getDefaultConnection();
         if ($this->option('database') !== null) {
             DB::setDefaultConnection($this->option('database'));
-            $this->searchService->setConnection(DB::connection($this->option('database')));
         }
 
-        $this->searchService->indexAllEntities();
+        $this->searchIndex->indexAllEntities();
         DB::setDefaultConnection($connection);
         $this->comment('Search index regenerated');
     }
diff --git a/app/Console/Commands/ResetMfa.php b/app/Console/Commands/ResetMfa.php
new file mode 100644 (file)
index 0000000..031bec0
--- /dev/null
@@ -0,0 +1,77 @@
+<?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;
+        }
+
+        /** @var User $user */
+        $field = $id ? 'id' : 'email';
+        $value = $id ?: $email;
+        $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 b95e277d176f6ba2f50779dd82bdd3badd6c9cb3..a4bb6cf228874f1d4b928c7faedc6baa704b27d0 100644 (file)
@@ -48,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;
         }
 
@@ -57,25 +58,55 @@ class UpdateUrl extends Command
         }
 
         $columnsToUpdateByTable = [
-            "attachments" => ["path"],
-            "pages" => ["html", "text", "markdown"],
-            "images" => ["url"],
-            "comments" => ["html", "text"],
+            'attachments' => ['path'],
+            'pages'       => ['html', 'text', 'markdown'],
+            'images'      => ['url'],
+            'settings'    => ['value'],
+            'comments'    => ['html', 'text'],
         ];
 
         foreach ($columnsToUpdateByTable as $table => $columns) {
             foreach ($columns as $column) {
-                $changeCount = $this->db->table($table)->update([
-                    $column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')")
-                ]);
+                $changeCount = $this->replaceValueInTable($table, $column, $oldUrl, $newUrl);
                 $this->info("Updated {$changeCount} rows in {$table}->{$column}");
             }
         }
 
-        $this->info("URL update procedure complete.");
+        $jsonColumnsToUpdateByTable = [
+            'settings' => ['value'],
+        ];
+
+        foreach ($jsonColumnsToUpdateByTable as $table => $columns) {
+            foreach ($columns as $column) {
+                $oldJson = trim(json_encode($oldUrl), '"');
+                $newJson = trim(json_encode($newUrl), '"');
+                $changeCount = $this->replaceValueInTable($table, $column, $oldJson, $newJson);
+                $this->info("Updated {$changeCount} JSON encoded rows in {$table}->{$column}");
+            }
+        }
+
+        $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;
     }
 
+    /**
+     * Perform a find+replace operations in the provided table and column.
+     * Returns the count of rows changed.
+     */
+    protected function replaceValueInTable(string $table, string $column, string $oldUrl, string $newUrl): int
+    {
+        $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})"),
+        ]);
+    }
+
     /**
      * Warn the user of the dangers of this operation.
      * Returns a boolean indicating if they've accepted the warnings.
@@ -83,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 43d63d026021dd07edcb1165841018ee213b13a3..797162dfb4fb586e8c0b536bb22bd0dea548bd38 100644 (file)
@@ -1,24 +1,28 @@
-<?php namespace BookStack\Entities;
+<?php
 
-use BookStack\Entities\Managers\EntityContext;
+namespace BookStack\Entities;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ShelfContext;
 use Illuminate\View\View;
 
 class BreadcrumbsViewComposer
 {
-
     protected $entityContextManager;
 
     /**
      * BreadcrumbsViewComposer constructor.
-     * @param EntityContext $entityContextManager
+     *
+     * @param ShelfContext $entityContextManager
      */
-    public function __construct(EntityContext $entityContextManager)
+    public function __construct(ShelfContext $entityContextManager)
     {
         $this->entityContextManager = $entityContextManager;
     }
 
     /**
      * Modify data when the view is composed.
+     *
      * @param View $view
      */
     public function compose(View $view)
diff --git a/app/Entities/Chapter.php b/app/Entities/Chapter.php
deleted file mode 100644 (file)
index 3290afc..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-<?php namespace BookStack\Entities;
-
-use Illuminate\Support\Collection;
-
-/**
- * Class Chapter
- * @property Collection<Page> $pages
- * @package BookStack\Entities
- */
-class Chapter extends BookChild
-{
-    public $searchFactor = 1.3;
-
-    protected $fillable = ['name', 'description', 'priority', 'book_id'];
-    protected $hidden = ['restricted', 'pivot'];
-
-    /**
-     * Get the pages that this chapter contains.
-     * @param string $dir
-     * @return mixed
-     */
-    public function pages($dir = 'ASC')
-    {
-        return $this->hasMany(Page::class)->orderBy('priority', $dir);
-    }
-
-    /**
-     * Get the url of this chapter.
-     * @param string|bool $path
-     * @return string
-     */
-    public function getUrl($path = false)
-    {
-        $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
-        $fullPath = '/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug);
-
-        if ($path !== false) {
-            $fullPath .= '/' . trim($path, '/');
-        }
-
-        return url($fullPath);
-    }
-
-    /**
-     * Get an excerpt of this chapter's description to the specified length or less.
-     * @param int $length
-     * @return string
-     */
-    public function getExcerpt(int $length = 100)
-    {
-        $description = $this->text ?? $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
-    }
-
-    /**
-     * Check if this chapter has any child pages.
-     * @return bool
-     */
-    public function hasChildren()
-    {
-        return count($this->pages) > 0;
-    }
-
-    /**
-     * Get the visible pages in this chapter.
-     */
-    public function getVisiblePages(): Collection
-    {
-        return $this->pages()->visible()
-        ->orderBy('draft', 'desc')
-        ->orderBy('priority', 'asc')
-        ->get();
-    }
-}
index 6bf923b3112aa8e7387fd6eedeb601a601511dad..aaf392c7b2782b7f54199d4d7398ce8a3059c7d8 100644 (file)
@@ -1,17 +1,23 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities;
+
+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\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
  * so this is a neater alternative to injecting all in individually.
- *
- * @package BookStack\Entities
  */
 class EntityProvider
 {
-
     /**
      * @var Bookshelf
      */
@@ -37,34 +43,28 @@ class EntityProvider
      */
     public $pageRevision;
 
-    /**
-     * EntityProvider constructor.
-     */
-    public function __construct(
-        Bookshelf $bookshelf,
-        Book $book,
-        Chapter $chapter,
-        Page $page,
-        PageRevision $pageRevision
-    ) {
-        $this->bookshelf = $bookshelf;
-        $this->book = $book;
-        $this->chapter = $chapter;
-        $this->page = $page;
-        $this->pageRevision = $pageRevision;
+    public function __construct()
+    {
+        $this->bookshelf = new Bookshelf();
+        $this->book = new Book();
+        $this->chapter = new Chapter();
+        $this->page = new Page();
+        $this->pageRevision = new PageRevision();
     }
 
     /**
      * 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,
         ];
     }
 
@@ -74,6 +74,7 @@ class EntityProvider
     public function get(string $type): Entity
     {
         $type = strtolower($type);
+
         return $this->all()[$type];
     }
 
@@ -87,6 +88,7 @@ class EntityProvider
             $model = $this->get($type);
             $morphClasses[] = $model->getMorphClass();
         }
+
         return $morphClasses;
     }
 }
diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php
deleted file mode 100644 (file)
index 1a32294..0000000
+++ /dev/null
@@ -1,109 +0,0 @@
-<?php namespace BookStack\Entities\Managers;
-
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\HasCoverImage;
-use BookStack\Entities\Page;
-use BookStack\Exceptions\NotifyException;
-use BookStack\Facades\Activity;
-use BookStack\Uploads\AttachmentService;
-use BookStack\Uploads\ImageService;
-use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
-
-class TrashCan
-{
-
-    /**
-     * Remove a bookshelf from the system.
-     * @throws Exception
-     */
-    public function destroyShelf(Bookshelf $shelf)
-    {
-        $this->destroyCommonRelations($shelf);
-        $shelf->delete();
-    }
-
-    /**
-     * Remove a book from the system.
-     * @throws NotifyException
-     * @throws BindingResolutionException
-     */
-    public function destroyBook(Book $book)
-    {
-        foreach ($book->pages as $page) {
-            $this->destroyPage($page);
-        }
-
-        foreach ($book->chapters as $chapter) {
-            $this->destroyChapter($chapter);
-        }
-
-        $this->destroyCommonRelations($book);
-        $book->delete();
-    }
-
-    /**
-     * Remove a page from the system.
-     * @throws NotifyException
-     */
-    public function destroyPage(Page $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());
-            }
-            setting()->remove('app-homepage');
-        }
-
-        $this->destroyCommonRelations($page);
-
-        // Delete Attached Files
-        $attachmentService = app(AttachmentService::class);
-        foreach ($page->attachments as $attachment) {
-            $attachmentService->deleteFile($attachment);
-        }
-
-        $page->delete();
-    }
-
-    /**
-     * Remove a chapter from the system.
-     * @throws Exception
-     */
-    public function destroyChapter(Chapter $chapter)
-    {
-        if (count($chapter->pages) > 0) {
-            foreach ($chapter->pages as $page) {
-                $page->chapter_id = 0;
-                $page->save();
-            }
-        }
-
-        $this->destroyCommonRelations($chapter);
-        $chapter->delete();
-    }
-
-    /**
-     * Update entity relations to remove or update outstanding connections.
-     */
-    protected function destroyCommonRelations(Entity $entity)
-    {
-        Activity::removeEntity($entity);
-        $entity->views()->delete();
-        $entity->permissions()->delete();
-        $entity->tags()->delete();
-        $entity->comments()->delete();
-        $entity->jointPermissions()->delete();
-        $entity->searchTerms()->delete();
-
-        if ($entity instanceof HasCoverImage && $entity->cover) {
-            $imageService = app()->make(ImageService::class);
-            $imageService->destroy($entity->cover);
-        }
-    }
-}
similarity index 72%
rename from app/Entities/Book.php
rename to app/Entities/Models/Book.php
index af8344b88f5cb440b9abeb6913e3e55ffeda68f6..1e4591bd75d3f385e5a4cffe217b5f8c7f643f2c 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
 use Exception;
@@ -8,36 +10,36 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Support\Collection;
 
 /**
- * Class Book
- * @property string $description
- * @property int $image_id
- * @property Image|null $cover
- * @package BookStack\Entities
+ * 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;
 
     protected $fillable = ['name', 'description'];
-    protected $hidden = ['restricted', 'pivot', 'image_id'];
+    protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
 
     /**
      * Get the url for this book.
-     * @param string|bool $path
-     * @return string
      */
-    public function getUrl($path = false)
+    public function getUrl(string $path = ''): string
     {
-        if ($path !== false) {
-            return url('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
-        }
-        return url('/books/' . urlencode($this->slug));
+        return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
     /**
      * 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)
@@ -52,11 +54,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
     {
@@ -73,6 +76,7 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get all pages within this book.
+     *
      * @return HasMany
      */
     public function pages()
@@ -82,6 +86,7 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get the direct child pages of this book.
+     *
      * @return HasMany
      */
     public function directPages()
@@ -91,6 +96,7 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get all chapters within this book.
+     *
      * @return HasMany
      */
     public function chapters()
@@ -100,6 +106,7 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get the shelves this book is contained within.
+     *
      * @return BelongsToMany
      */
     public function shelves()
@@ -109,23 +116,14 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Get the direct child items within this book.
+     *
      * @return Collection
      */
     public function getDirectChildren(): Collection
     {
         $pages = $this->directPages()->visible()->get();
         $chapters = $this->chapters()->visible()->get();
-        return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
-    }
 
-    /**
-     * Get an excerpt of this book's description to the specified length or less.
-     * @param int $length
-     * @return string
-     */
-    public function getExcerpt(int $length = 100)
-    {
-        $description = $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
+        return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
     }
 }
similarity index 55%
rename from app/Entities/BookChild.php
rename to app/Entities/Models/BookChild.php
index 6eac4375ddce6c271a06669a8eaa108b774d55e2..e1ba0b6f708d75a18f5cb38dc38262e7396d61c0 100644 (file)
@@ -1,20 +1,38 @@
-<?php namespace BookStack\Entities;
+<?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)
  */
-class BookChild extends Entity
+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)
@@ -28,11 +46,10 @@ class BookChild extends Entity
 
     /**
      * Get the book this page sits in.
-     * @return BelongsTo
      */
     public function book(): BelongsTo
     {
-        return $this->belongsTo(Book::class);
+        return $this->belongsTo(Book::class)->withTrashed();
     }
 
     /**
@@ -45,12 +62,9 @@ class BookChild extends Entity
         $this->save();
         $this->refresh();
 
-        // Update related activity
-        $this->activity()->update(['book_id' => $newBookId]);
-
         // Update all child pages if a chapter
         if ($this instanceof Chapter) {
-            foreach ($this->pages as $page) {
+            foreach ($this->pages()->withTrashed()->get() as $page) {
                 $page->changeBook($newBookId);
             }
         }
similarity index 76%
rename from app/Entities/Bookshelf.php
rename to app/Entities/Models/Bookshelf.php
index 474ba51cd8204bf27dfc95ad421029cdaa8e7375..f427baf49fcea4c0bb616eb9270302ffd6f85728 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,11 +14,12 @@ class Bookshelf extends Entity implements HasCoverImage
 
     protected $fillable = ['name', 'description', 'image_id'];
 
-    protected $hidden = ['restricted', 'image_id'];
+    protected $hidden = ['restricted', 'image_id', 'deleted_at'];
 
     /**
      * 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()
@@ -36,21 +39,18 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Get the url for this bookshelf.
-     * @param string|bool $path
-     * @return string
      */
-    public function getUrl($path = false)
+    public function getUrl(string $path = ''): string
     {
-        if ($path !== false) {
-            return url('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
-        }
-        return url('/shelves/' . urlencode($this->slug));
+        return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
     }
 
     /**
      * 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)
@@ -66,11 +66,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
     {
@@ -85,20 +86,11 @@ class Bookshelf extends Entity implements HasCoverImage
         return 'cover_shelf';
     }
 
-    /**
-     * Get an excerpt of this book's description to the specified length or less.
-     * @param int $length
-     * @return string
-     */
-    public function getExcerpt(int $length = 100)
-    {
-        $description = $this->description;
-        return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
-    }
-
     /**
      * Check if this shelf contains the given book.
+     *
      * @param Book $book
+     *
      * @return bool
      */
     public function contains(Book $book): bool
@@ -108,6 +100,7 @@ class Bookshelf extends Entity implements HasCoverImage
 
     /**
      * Add a book to the end of this shelf.
+     *
      * @param Book $book
      */
     public function appendBook(Book $book)
diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php
new file mode 100644 (file)
index 0000000..f6f8427
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use Illuminate\Support\Collection;
+
+/**
+ * Class Chapter.
+ *
+ * @property Collection<Page> $pages
+ * @property mixed description
+ */
+class Chapter extends BookChild
+{
+    public $searchFactor = 1.3;
+
+    protected $fillable = ['name', 'description', 'priority', 'book_id'];
+    protected $hidden = ['restricted', 'pivot', 'deleted_at'];
+
+    /**
+     * Get the pages that this chapter contains.
+     *
+     * @param string $dir
+     *
+     * @return mixed
+     */
+    public function pages($dir = 'ASC')
+    {
+        return $this->hasMany(Page::class)->orderBy('priority', $dir);
+    }
+
+    /**
+     * Get the url of this chapter.
+     */
+    public function getUrl($path = ''): string
+    {
+        $parts = [
+            'books',
+            urlencode($this->book_slug ?? $this->book->slug),
+            'chapter',
+            urlencode($this->slug),
+            trim($path, '/'),
+        ];
+
+        return url('/' . implode('/', $parts));
+    }
+
+    /**
+     * Get the visible pages in this chapter.
+     */
+    public function getVisiblePages(): Collection
+    {
+        return $this->pages()->visible()
+        ->orderBy('draft', 'desc')
+        ->orderBy('priority', 'asc')
+        ->get();
+    }
+}
diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php
new file mode 100644 (file)
index 0000000..764c4a1
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Auth\User;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+/**
+ * @property Model deletable
+ */
+class Deletion extends Model implements Loggable
+{
+    /**
+     * Get the related deletable record.
+     */
+    public function deletable(): MorphTo
+    {
+        return $this->morphTo('deletable')->withTrashed();
+    }
+
+    /**
+     * The the user that performed the deletion.
+     */
+    public function deleter(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'deleted_by');
+    }
+
+    /**
+     * Create a new deletion record for the provided entity.
+     */
+    public static function createForEntity(Entity $entity): Deletion
+    {
+        $record = (new self())->forceFill([
+            'deleted_by'     => user()->id,
+            'deletable_type' => $entity->getMorphClass(),
+            '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}";
+    }
+
+    /**
+     * Get a URL for this specific deletion.
+     */
+    public function getUrl($path): string
+    {
+        return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
+    }
+}
similarity index 60%
rename from app/Entities/Entity.php
rename to app/Entities/Models/Entity.php
index 6a5894cacb91941115e6e01225fd2aa5b100c89b..a02926c4dee341aa6d89d16f94fb425434c95bbb 100644 (file)
@@ -1,41 +1,54 @@
-<?php namespace BookStack\Entities;
+<?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;
 use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Entities\Tools\SearchIndex;
+use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Facades\Permissions;
-use BookStack\Ownable;
+use BookStack\Interfaces\Favouritable;
+use BookStack\Interfaces\Sluggable;
+use BookStack\Interfaces\Viewable;
+use BookStack\Model;
+use BookStack\Traits\HasCreatorAndUpdater;
+use BookStack\Traits\HasOwner;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Collection;
 use Illuminate\Database\Eloquent\Relations\MorphMany;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
  * Class Entity
  * The base class for book-like items such as pages, chapters & books.
  * 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 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()
- *
- * @package BookStack\Entities
  */
-class Entity extends Ownable
+abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
 {
+    use SoftDeletes;
+    use HasCreatorAndUpdater;
+    use HasOwner;
 
     /**
      * @var string - Name of property where the main text content is found
@@ -50,7 +63,7 @@ class Entity extends Ownable
     /**
      * Get the entities that are visible to the current user.
      */
-    public function scopeVisible(Builder $query)
+    public function scopeVisible(Builder $query): Builder
     {
         return $this->scopeHasPermission($query, 'view');
     }
@@ -92,24 +105,18 @@ class Entity extends Ownable
     /**
      * Compares this entity to another given entity.
      * Matches by comparing class and id.
-     * @param $entity
-     * @return bool
      */
-    public function matches($entity)
+    public function matches(Entity $entity): bool
     {
         return [get_class($this), $this->id] === [get_class($entity), $entity->id];
     }
 
     /**
-     * Checks if an entity matches or contains another given entity.
-     * @param Entity $entity
-     * @return bool
+     * Checks if the current entity matches or contains the given.
      */
-    public function matchesOrContains(Entity $entity)
+    public function matchesOrContains(Entity $entity): bool
     {
-        $matches = [get_class($this), $this->id] === [get_class($entity), $entity->id];
-
-        if ($matches) {
+        if ($this->matches($entity)) {
             return true;
         }
 
@@ -126,9 +133,8 @@ class Entity extends Ownable
 
     /**
      * Gets the activity objects for this entity.
-     * @return MorphMany
      */
-    public function activity()
+    public function activity(): MorphMany
     {
         return $this->morphMany(Activity::class, 'entity')
             ->orderBy('created_at', 'desc');
@@ -137,36 +143,33 @@ class Entity extends Ownable
     /**
      * Get View objects for this entity.
      */
-    public function views()
+    public function views(): MorphMany
     {
         return $this->morphMany(View::class, 'viewable');
     }
 
     /**
      * Get the Tag models that have been user assigned to this entity.
-     * @return MorphMany
      */
-    public function tags()
+    public function tags(): MorphMany
     {
         return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
     }
 
     /**
-     * Get the comments for an entity
-     * @param bool $orderByCreated
-     * @return MorphMany
+     * Get the comments for an entity.
      */
-    public function comments($orderByCreated = true)
+    public function comments(bool $orderByCreated = true): MorphMany
     {
         $query = $this->morphMany(Comment::class, 'entity');
+
         return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
     }
 
     /**
      * Get the related search terms.
-     * @return MorphMany
      */
-    public function searchTerms()
+    public function searchTerms(): MorphMany
     {
         return $this->morphMany(SearchTerm::class, 'entity');
     }
@@ -174,18 +177,15 @@ class Entity extends Ownable
     /**
      * Get this entities restrictions.
      */
-    public function permissions()
+    public function permissions(): MorphMany
     {
         return $this->morphMany(EntityPermission::class, 'restrictable');
     }
 
     /**
      * Check if this entity has a specific restriction set against it.
-     * @param $role_id
-     * @param $action
-     * @return bool
      */
-    public function hasRestriction($role_id, $action)
+    public function hasRestriction(int $role_id, string $action): bool
     {
         return $this->permissions()->where('role_id', '=', $role_id)
             ->where('action', '=', $action)->count() > 0;
@@ -193,93 +193,93 @@ class Entity extends Ownable
 
     /**
      * Get the entity jointPermissions this is connected to.
-     * @return MorphMany
      */
-    public function jointPermissions()
+    public function jointPermissions(): MorphMany
     {
         return $this->morphMany(JointPermission::class, 'entity');
     }
 
     /**
-     * Allows checking of the exact class, Used to check entity type.
-     * Cleaner method for is_a.
-     * @param $type
-     * @return bool
+     * Get the related delete records for this entity.
      */
-    public static function isA($type)
+    public function deletions(): MorphMany
     {
-        return static::getType() === strtolower($type);
+        return $this->morphMany(Deletion::class, 'deletable');
     }
 
     /**
-     * Get entity type.
-     * @return mixed
+     * Check if this instance or class is a certain type of entity.
+     * Examples of $type are 'page', 'book', 'chapter'.
      */
-    public static function getType()
+    public static function isA(string $type): bool
     {
-        return strtolower(static::getClassName());
+        return static::getType() === strtolower($type);
     }
 
     /**
-     * Get an instance of an entity of the given type.
-     * @param $type
-     * @return Entity
+     * Get the entity type as a simple lowercase word.
      */
-    public static function getEntityInstance($type)
+    public static function getType(): string
     {
-        $types = ['Page', 'Book', 'Chapter', 'Bookshelf'];
-        $className = str_replace([' ', '-', '_'], '', ucwords($type));
-        if (!in_array($className, $types)) {
-            return null;
-        }
+        $className = array_slice(explode('\\', static::class), -1, 1)[0];
 
-        return app('BookStack\\Entities\\' . $className);
+        return strtolower($className);
     }
 
     /**
      * Gets a limited-length version of the entities name.
-     * @param int $length
-     * @return string
      */
-    public function getShortName($length = 25)
+    public function getShortName(int $length = 25): string
     {
         if (mb_strlen($this->name) <= $length) {
             return $this->name;
         }
+
         return mb_substr($this->name, 0, $length - 3) . '...';
     }
 
     /**
      * Get the body text of this entity.
-     * @return mixed
      */
-    public function getText()
+    public function getText(): string
     {
-        return $this->{$this->textField};
+        return $this->{$this->textField} ?? '';
     }
 
     /**
      * Get an excerpt of this entity's descriptive content to the specified length.
-     * @param int $length
-     * @return mixed
      */
-    public function getExcerpt(int $length = 100)
+    public function getExcerpt(int $length = 100): string
     {
         $text = $this->getText();
+
         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
-     * @param $path
-     * @return string
+     * Get the url of this entity.
      */
-    public function getUrl($path = '/')
+    abstract public function getUrl(string $path = '/'): string;
+
+    /**
+     * Get the parent entity if existing.
+     * This is the "static" parent and does not include dynamic
+     * relations such as shelves to books.
+     */
+    public function getParent(): ?Entity
     {
-        return $path;
+        if ($this instanceof Page) {
+            return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
+        }
+        if ($this instanceof Chapter) {
+            return $this->book()->withTrashed()->first();
+        }
+
+        return null;
     }
 
     /**
@@ -292,21 +292,38 @@ class Entity extends Ownable
     }
 
     /**
-     * Index the current entity for search
+     * Index the current entity for search.
      */
     public function indexForSearch()
     {
-        $searchService = app()->make(SearchService::class);
-        $searchService->indexEntity(clone $this);
+        app(SearchIndex::class)->indexEntity(clone $this);
     }
 
     /**
-     * Generate and set a new URL slug for this model.
+     * @inheritdoc
      */
     public function refreshSlug(): string
     {
-        $generator = new SlugGenerator($this);
-        $this->slug = $generator->generate();
+        $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();
+    }
 }
similarity index 90%
rename from app/Entities/HasCoverImage.php
rename to app/Entities/Models/HasCoverImage.php
index 31277f4b69c59bb659c2842277015ffbccca843c..f665efce6d24c8ec6ebb9a25f5f05f92613b80e2 100644 (file)
@@ -1,13 +1,11 @@
 <?php
 
-
-namespace BookStack\Entities;
+namespace BookStack\Entities\Models;
 
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 interface HasCoverImage
 {
-
     /**
      * Get the cover image for this item.
      */
diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php
new file mode 100644 (file)
index 0000000..b8467c3
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Uploads\Attachment;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
+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
+ * @property Collection $attachments
+ */
+class Page extends BookChild
+{
+    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 $fillable = ['name', 'priority', 'markdown'];
+
+    public $textField = 'text';
+
+    protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
+
+    protected $casts = [
+        'draft'    => 'boolean',
+        'template' => 'boolean',
+    ];
+
+    /**
+     * Get the entities that are visible to the current user.
+     */
+    public function scopeVisible(Builder $query): Builder
+    {
+        $query = Permissions::enforceDraftVisibilityOnQuery($query);
+
+        return parent::scopeVisible($query);
+    }
+
+    /**
+     * Get the chapter that this page is in, If applicable.
+     *
+     * @return BelongsTo
+     */
+    public function chapter()
+    {
+        return $this->belongsTo(Chapter::class);
+    }
+
+    /**
+     * Check if this page has a chapter.
+     *
+     * @return bool
+     */
+    public function hasChapter()
+    {
+        return $this->chapter()->count() > 0;
+    }
+
+    /**
+     * Get the associated page revisions, ordered by created date.
+     * Only provides actual saved page revision instances, Not drafts.
+     */
+    public function revisions(): HasMany
+    {
+        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()
+    {
+        return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
+    }
+
+    /**
+     * Get the url of this page.
+     */
+    public function getUrl($path = ''): string
+    {
+        $parts = [
+            'books',
+            urlencode($this->book_slug ?? $this->book->slug),
+            $this->draft ? 'draft' : 'page',
+            $this->draft ? $this->id : urlencode($this->slug),
+            trim($path, '/'),
+        ];
+
+        return url('/' . implode('/', $parts));
+    }
+
+    /**
+     * Get the current revision for the page if existing.
+     *
+     * @return PageRevision|null
+     */
+    public function getCurrentRevision()
+    {
+        return $this->revisions()->first();
+    }
+
+    /**
+     * Get this page for JSON display.
+     */
+    public function forJsonDisplay(): Page
+    {
+        $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;
+    }
+}
similarity index 74%
rename from app/Entities/PageRevision.php
rename to app/Entities/Models/PageRevision.php
index 13dc713ba43be37453ca525f06e91f143793476e..b994e7a04ceec606e56757362e87a74372311dad 100644 (file)
@@ -1,47 +1,53 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities\Models;
 
 use BookStack\Auth\User;
 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
  */
 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)
@@ -50,11 +56,13 @@ class PageRevision extends Model
         if ($path) {
             return $url . '/' . trim($path, '/');
         }
+
         return $url;
     }
 
     /**
-     * Get the previous revision for the same page if existing
+     * Get the previous revision for the same page if existing.
+     *
      * @return \BookStack\Entities\PageRevision|null
      */
     public function getPrevious()
@@ -73,8 +81,10 @@ 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)
+     * (Yup, Bit of an awkward hack).
+     *
      * @param $type
+     *
      * @return bool
      */
     public static function isA($type)
similarity index 76%
rename from app/Entities/SearchTerm.php
rename to app/Entities/Models/SearchTerm.php
index 886c4dbc1fe4a4041859357ebe293b8ecb79177d..4ec8d6c45ac1ad027322d0ccbb26fec418e9e468 100644 (file)
@@ -1,15 +1,17 @@
-<?php namespace BookStack\Entities;
+<?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/Page.php b/app/Entities/Page.php
deleted file mode 100644 (file)
index 32ba298..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-<?php namespace BookStack\Entities;
-
-use BookStack\Uploads\Attachment;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Eloquent\Collection;
-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
- * @property Collection $attachments
- */
-class Page extends BookChild
-{
-    protected $fillable = ['name', 'priority', 'markdown'];
-
-    protected $simpleAttributes = ['name', 'id', 'slug'];
-
-    public $textField = 'text';
-
-    protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
-
-    /**
-     * Get the entities that are visible to the current user.
-     */
-    public function scopeVisible(Builder $query)
-    {
-        $query = Permissions::enforceDraftVisiblityOnQuery($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;
-    }
-
-    /**
-     * Get the parent item
-     */
-    public function parent(): Entity
-    {
-        return $this->chapter_id ? $this->chapter : $this->book;
-    }
-
-    /**
-     * Get the chapter that this page is in, If applicable.
-     * @return BelongsTo
-     */
-    public function chapter()
-    {
-        return $this->belongsTo(Chapter::class);
-    }
-
-    /**
-     * Check if this page has a chapter.
-     * @return bool
-     */
-    public function hasChapter()
-    {
-        return $this->chapter()->count() > 0;
-    }
-
-    /**
-     * Get the associated page revisions, ordered by created date.
-     * @return mixed
-     */
-    public function revisions()
-    {
-        return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
-    }
-
-    /**
-     * Get the attachments assigned to this page.
-     * @return HasMany
-     */
-    public function attachments()
-    {
-        return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
-    }
-
-    /**
-     * Get the url for this page.
-     * @param string|bool $path
-     * @return string
-     */
-    public function getUrl($path = false)
-    {
-        $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
-        $midText = $this->draft ? '/draft/' : '/page/';
-        $idComponent = $this->draft ? $this->id : urlencode($this->slug);
-
-        $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
-        if ($path !== false) {
-            $url .= '/' . trim($path, '/');
-        }
-
-        return url($url);
-    }
-
-    /**
-     * Get the current revision for the page if existing
-     * @return PageRevision|null
-     */
-    public function getCurrentRevision()
-    {
-        return $this->revisions()->first();
-    }
-}
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 7c25e49813e18bf7f34b42ce68df72c780f141fa..5699185031b12129de12f622c375f4a48980b4ac 100644 (file)
@@ -3,25 +3,17 @@
 namespace BookStack\Entities\Repos;
 
 use BookStack\Actions\TagRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\Entity;
-use BookStack\Entities\HasCoverImage;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\HasCoverImage;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Uploads\ImageRepo;
 use Illuminate\Http\UploadedFile;
-use Illuminate\Support\Collection;
 
 class BaseRepo
 {
-
     protected $tagRepo;
     protected $imageRepo;
 
-
-    /**
-     * BaseRepo constructor.
-     * @param $tagRepo
-     */
     public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
     {
         $this->tagRepo = $tagRepo;
@@ -29,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)
     {
@@ -37,6 +29,7 @@ class BaseRepo
         $entity->forceFill([
             'created_by' => user()->id,
             'updated_by' => user()->id,
+            'owned_by'   => user()->id,
         ]);
         $entity->refreshSlug();
         $entity->save();
@@ -73,6 +66,7 @@ class BaseRepo
 
     /**
      * Update the given items' cover image, or clear it.
+     *
      * @throws ImageUploadException
      * @throws \Exception
      */
@@ -91,29 +85,4 @@ class BaseRepo
             $entity->save();
         }
     }
-
-    /**
-     * Update the permissions of an entity.
-     */
-    public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null)
-    {
-        $entity->restricted = $restricted;
-        $entity->permissions()->delete();
-
-        if (!is_null($permissions)) {
-            $entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) {
-                return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
-                    return [
-                        'role_id' => $roleId,
-                        'action' => strtolower($action),
-                    ] ;
-                });
-            });
-
-            $entity->permissions()->createMany($entityPermissionData);
-        }
-
-        $entity->save();
-        $entity->rebuildPermissions();
-    }
 }
index 70db0fa65750bde4266c97040939d7a0b55c098a..a692bbaf75a8bf4dca27a62a8b6c133ecc7437de 100644 (file)
@@ -1,28 +1,28 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
 
+namespace BookStack\Entities\Repos;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Actions\TagRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\TrashCan;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
 use BookStack\Uploads\ImageRepo;
 use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Support\Collection;
 
 class BookRepo
 {
-
     protected $baseRepo;
     protected $tagRepo;
     protected $imageRepo;
 
     /**
      * BookRepo constructor.
-     * @param $tagRepo
      */
     public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
     {
@@ -36,7 +36,7 @@ class BookRepo
      */
     public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
     {
-        return Book::visible()->orderBy($sort, $order)->paginate($count);
+        return Book::visible()->with('cover')->orderBy($sort, $order)->paginate($count);
     }
 
     /**
@@ -85,12 +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);
+
         return $book;
     }
 
@@ -100,11 +102,14 @@ class BookRepo
     public function update(Book $book, array $input): Book
     {
         $this->baseRepo->update($book, $input);
+        Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
+
         return $book;
     }
 
     /**
      * Update the given book's cover image, or clear it.
+     *
      * @throws ImageUploadException
      * @throws Exception
      */
@@ -113,22 +118,17 @@ class BookRepo
         $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
     }
 
-    /**
-     * Update the permissions of a book.
-     */
-    public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null)
-    {
-        $this->baseRepo->updatePermissions($book, $restricted, $permissions);
-    }
-
     /**
      * Remove a book from the system.
-     * @throws NotifyException
-     * @throws BindingResolutionException
+     *
+     * @throws Exception
      */
     public function destroy(Book $book)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyBook($book);
+        $trashCan->softDestroyBook($book);
+        Activity::addForEntity($book, ActivityType::BOOK_DELETE);
+
+        $trashCan->autoClearOld();
     }
 }
index ba687c6f6e754f3a49959ad932294620cb3e74c8..3990bfbdcf02f5fc2f74a546f9045d200af1a4ac 100644 (file)
@@ -1,10 +1,14 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Managers\TrashCan;
+namespace BookStack\Entities\Repos;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Facades\Activity;
 use Exception;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 use Illuminate\Http\UploadedFile;
@@ -16,7 +20,6 @@ class BookshelfRepo
 
     /**
      * BookshelfRepo constructor.
-     * @param $baseRepo
      */
     public function __construct(BaseRepo $baseRepo)
     {
@@ -29,7 +32,7 @@ class BookshelfRepo
     public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
     {
         return Bookshelf::visible()
-            ->with('visibleBooks')
+            ->with(['visibleBooks', 'cover'])
             ->orderBy($sort, $order)
             ->paginate($count);
     }
@@ -87,11 +90,13 @@ class BookshelfRepo
         $shelf = new Bookshelf();
         $this->baseRepo->create($shelf, $input);
         $this->updateBooks($shelf, $bookIds);
+        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
+
         return $shelf;
     }
 
     /**
-     * Create a new shelf in the system.
+     * Update an existing shelf in the system using the given input.
      */
     public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
     {
@@ -101,6 +106,8 @@ class BookshelfRepo
             $this->updateBooks($shelf, $bookIds);
         }
 
+        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
+
         return $shelf;
     }
 
@@ -126,6 +133,7 @@ class BookshelfRepo
 
     /**
      * Update the given shelf cover image, or clear it.
+     *
      * @throws ImageUploadException
      * @throws Exception
      */
@@ -134,14 +142,6 @@ class BookshelfRepo
         $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
     }
 
-    /**
-     * Update the permissions of a bookshelf.
-     */
-    public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null)
-    {
-        $this->baseRepo->updatePermissions($shelf, $restricted, $permissions);
-    }
-
     /**
      * Copy down the permissions of the given shelf to all child books.
      */
@@ -169,11 +169,14 @@ class BookshelfRepo
 
     /**
      * Remove a bookshelf from the system.
+     *
      * @throws Exception
      */
     public function destroy(Bookshelf $shelf)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyShelf($shelf);
+        $trashCan->softDestroyShelf($shelf);
+        Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
+        $trashCan->autoClearOld();
     }
 }
index c6f3a2d2f0fc093c37b6e541081c20c977468c75..68330dd57bf5a02135db0765280fccd730e09bbe 100644 (file)
@@ -1,25 +1,23 @@
-<?php namespace BookStack\Entities\Repos;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\TrashCan;
+namespace BookStack\Entities\Repos;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
 use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Support\Collection;
 
 class ChapterRepo
 {
-
     protected $baseRepo;
 
     /**
      * ChapterRepo constructor.
-     * @param $baseRepo
      */
     public function __construct(BaseRepo $baseRepo)
     {
@@ -28,6 +26,7 @@ class ChapterRepo
 
     /**
      * Get a chapter via the slug.
+     *
      * @throws NotFoundException
      */
     public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
@@ -50,6 +49,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);
+
         return $chapter;
     }
 
@@ -59,31 +60,29 @@ class ChapterRepo
     public function update(Chapter $chapter, array $input): Chapter
     {
         $this->baseRepo->update($chapter, $input);
-        return $chapter;
-    }
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
 
-    /**
-     * Update the permissions of a chapter.
-     */
-    public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null)
-    {
-        $this->baseRepo->updatePermissions($chapter, $restricted, $permissions);
+        return $chapter;
     }
 
     /**
      * Remove a chapter from the system.
+     *
      * @throws Exception
      */
     public function destroy(Chapter $chapter)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyChapter($chapter);
+        $trashCan->softDestroyChapter($chapter);
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
+        $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
      */
     public function move(Chapter $chapter, string $parentIdentifier): Book
@@ -96,6 +95,7 @@ class ChapterRepo
             throw new MoveOperationException('Chapters can only be moved into books');
         }
 
+        /** @var Book $parent */
         $parent = Book::visible()->where('id', '=', $entityId)->first();
         if ($parent === null) {
             throw new MoveOperationException('Book to move chapter into not found');
@@ -103,6 +103,8 @@ class ChapterRepo
 
         $chapter->changeBook($parent->id);
         $chapter->rebuildPermissions();
+        Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
+
         return $parent;
     }
 }
index e5f13463c388f781dd215338afb4af37cefaa7c5..ffa06d45954c9a7269998c1a719c23fa81bbba56 100644 (file)
@@ -1,24 +1,26 @@
-<?php namespace BookStack\Entities\Repos;
-
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Managers\TrashCan;
-use BookStack\Entities\Page;
-use BookStack\Entities\PageRevision;
+<?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\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
+use BookStack\Facades\Activity;
+use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Pagination\LengthAwarePaginator;
-use Illuminate\Support\Collection;
 
 class PageRepo
 {
-
     protected $baseRepo;
 
     /**
@@ -31,11 +33,12 @@ class PageRepo
 
     /**
      * Get a page by ID.
+     *
      * @throws NotFoundException
      */
-    public function getById(int $id): Page
+    public function getById(int $id, array $relations = ['book']): Page
     {
-        $page = Page::visible()->with(['book'])->find($id);
+        $page = Page::visible()->with($relations)->find($id);
 
         if (!$page) {
             throw new NotFoundException(trans('errors.page_not_found'));
@@ -46,6 +49,7 @@ class PageRepo
 
     /**
      * Get a page its book and own slug.
+     *
      * @throws NotFoundException
      */
     public function getBySlug(string $bookSlug, string $pageSlug): Page
@@ -75,6 +79,7 @@ class PageRepo
             ->orderBy('created_at', 'desc')
             ->with('page')
             ->first();
+
         return $revision ? $revision->page : null;
     }
 
@@ -117,6 +122,7 @@ class PageRepo
     public function getUserDraft(Page $page): ?PageRevision
     {
         $revision = $this->getUserDraftQuery($page)->first();
+
         return $revision;
     }
 
@@ -126,10 +132,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,
             'updated_by' => user()->id,
-            'draft' => true,
+            'draft'      => true,
         ]);
 
         if ($parent instanceof Chapter) {
@@ -141,6 +148,7 @@ class PageRepo
 
         $page->save();
         $page->refresh()->rebuildPermissions();
+
         return $page;
     }
 
@@ -150,12 +158,8 @@ class PageRepo
     public function publishDraft(Page $draft, array $input): Page
     {
         $this->baseRepo->update($draft, $input);
-        if (isset($input['template']) && userCan('templates-manage')) {
-            $draft->template = ($input['template'] === 'true');
-        }
+        $this->updateTemplateStatusAndContentFromInput($draft, $input);
 
-        $pageContent = new PageContent($draft);
-        $pageContent->setNewHTML($input['html']);
         $draft->draft = false;
         $draft->revision_count = 1;
         $draft->priority = $this->getNewPriority($draft);
@@ -164,7 +168,11 @@ class PageRepo
 
         $this->savePageRevision($draft, trans('entities.pages_initial_revision'));
         $draft->indexForSearch();
-        return $draft->refresh();
+        $draft->refresh();
+
+        Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
+
+        return $draft;
     }
 
     /**
@@ -175,47 +183,53 @@ class PageRepo
         // Hold the old details to compare later
         $oldHtml = $page->html;
         $oldName = $page->name;
+        $oldMarkdown = $page->markdown;
 
-        if (isset($input['template']) && userCan('templates-manage')) {
-            $page->template = ($input['template'] === 'true');
-        }
-
-        $pageContent = new PageContent($page);
-        $pageContent->setNewHTML($input['html']);
+        $this->updateTemplateStatusAndContentFromInput($page, $input);
         $this->baseRepo->update($page, $input);
 
         // Update with new details
         $page->revision_count++;
-
-        if (setting('app-editor') !== 'markdown') {
-            $page->markdown = '';
-        }
-
         $page->save();
 
         // Remove all update drafts for this user & page.
         $this->getUserDraftQuery($page)->delete();
 
         // Save a revision after updating
-        $summary = $input['summary'] ?? null;
-        if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) {
+        $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;
+        if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
             $this->savePageRevision($page, $summary);
         }
 
+        Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
+
         return $page;
     }
 
+    protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
+    {
+        if (isset($input['template']) && userCan('templates-manage')) {
+            $page->template = ($input['template'] === 'true');
+        }
+
+        $pageContent = new PageContent($page);
+        if (!empty($input['markdown'] ?? '')) {
+            $pageContent->setNewMarkdown($input['markdown']);
+        } elseif (isset($input['html'])) {
+            $pageContent->setNewHTML($input['html']);
+        }
+    }
+
     /**
      * Saves a page revision into the system.
      */
-    protected function savePageRevision(Page $page, string $summary = null)
+    protected function savePageRevision(Page $page, string $summary = null): PageRevision
     {
         $revision = new PageRevision($page->getAttributes());
 
-        if (setting('app-editor') !== 'markdown') {
-            $revision->markdown = '';
-        }
-
         $revision->page_id = $page->id;
         $revision->slug = $page->slug;
         $revision->book_slug = $page->book->slug;
@@ -227,6 +241,7 @@ class PageRepo
         $revision->save();
 
         $this->deleteOldRevisions($page);
+
         return $revision;
     }
 
@@ -237,12 +252,12 @@ class PageRepo
     {
         // If the page itself is a draft simply update that
         if ($page->draft) {
-            $page->fill($input);
             if (isset($input['html'])) {
-                $content = new PageContent($page);
-                $content->setNewHTML($input['html']);
+                (new PageContent($page))->setNewHTML($input['html']);
             }
+            $page->fill($input);
             $page->save();
+
             return $page;
         }
 
@@ -254,17 +269,21 @@ class PageRepo
         }
 
         $draft->save();
+
         return $draft;
     }
 
     /**
      * Destroy a page from the system.
-     * @throws NotifyException
+     *
+     * @throws Exception
      */
     public function destroy(Page $page)
     {
         $trashCan = new TrashCan();
-        $trashCan->destroyPage($page);
+        $trashCan->softDestroyPage($page);
+        Activity::addForEntity($page, ActivityType::PAGE_DELETE);
+        $trashCan->autoClearOld();
     }
 
     /**
@@ -273,28 +292,39 @@ class PageRepo
     public function restoreRevision(Page $page, int $revisionId): Page
     {
         $page->revision_count++;
-        $this->savePageRevision($page);
-
         $revision = $page->revisions()->where('id', '=', $revisionId)->first();
+
         $page->fill($revision->toArray());
         $content = new PageContent($page);
-        $content->setNewHTML($revision->html);
+
+        if (!empty($revision->markdown)) {
+            $content->setNewMarkdown($revision->markdown);
+        } else {
+            $content->setNewHTML($revision->html);
+        }
+
         $page->updated_by = user()->id;
         $page->refreshSlug();
         $page->save();
-
         $page->indexForSearch();
+
+        $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
+        $this->savePageRevision($page, $summary);
+
+        Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
+
         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): Book
+    public function move(Page $page, string $parentIdentifier): Entity
     {
         $parent = $this->findParentByIdentifier($parentIdentifier);
         if ($parent === null) {
@@ -309,18 +339,21 @@ class PageRepo
         $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
         $page->rebuildPermissions();
 
-        return ($parent instanceof Book ? $parent : $parent->book);
+        Activity::addForEntity($page, ActivityType::PAGE_MOVE);
+
+        return $parent;
     }
 
     /**
      * 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->parent();
+        $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
         if ($parent === null) {
             throw new MoveOperationException('Book or chapter to move page into not found');
         }
@@ -351,7 +384,8 @@ class PageRepo
     /**
      * Find a page parent entity via a identifier string in the format:
      * {type}:{id}
-     * Example: (book:5)
+     * Example: (book:5).
+     *
      * @throws MoveOperationException
      */
     protected function findParentByIdentifier(string $identifier): ?Entity
@@ -365,15 +399,8 @@ class PageRepo
         }
 
         $parentClass = $entityType === 'book' ? Book::class : Chapter::class;
-        return $parentClass::visible()->where('id', '=', $entityId)->first();
-    }
 
-    /**
-     * Update the permissions of a page.
-     */
-    public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null)
-    {
-        $this->baseRepo->updatePermissions($page, $restricted, $permissions);
+        return $parentClass::visible()->where('id', '=', $entityId)->first();
     }
 
     /**
@@ -410,6 +437,7 @@ class PageRepo
         $draft->book_slug = $page->book->slug;
         $draft->created_by = user()->id;
         $draft->type = 'update_draft';
+
         return $draft;
     }
 
@@ -435,12 +463,14 @@ class PageRepo
     }
 
     /**
-     * Get a new priority for a page
+     * Get a new priority for a page.
      */
     protected function getNewPriority(Page $page): int
     {
-        if ($page->parent() instanceof Chapter) {
-            $lastPage = $page->parent()->pages('desc')->first();
+        $parent = $page->getParent();
+        if ($parent instanceof Chapter) {
+            $lastPage = $parent->pages('desc')->first();
+
             return $lastPage ? $lastPage->priority + 1 : 0;
         }
 
diff --git a/app/Entities/SlugGenerator.php b/app/Entities/SlugGenerator.php
deleted file mode 100644 (file)
index e8bc556..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php namespace BookStack\Entities;
-
-use Illuminate\Support\Str;
-
-class SlugGenerator
-{
-
-    protected $entity;
-
-    /**
-     * SlugGenerator constructor.
-     * @param $entity
-     */
-    public function __construct(Entity $entity)
-    {
-        $this->entity = $entity;
-    }
-
-    /**
-     * Generate a fresh slug for the given entity.
-     * The slug will generated so it does not conflict within the same parent item.
-     */
-    public function generate(): string
-    {
-        $slug = $this->formatNameAsSlug($this->entity->name);
-        while ($this->slugInUse($slug)) {
-            $slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
-        }
-        return $slug;
-    }
-
-    /**
-     * Format a name as a url slug.
-     */
-    protected function formatNameAsSlug(string $name): string
-    {
-        $slug = Str::slug($name);
-        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.
-     */
-    protected function slugInUse(string $slug): bool
-    {
-        $query = $this->entity->newQuery()->where('slug', '=', $slug);
-
-        if ($this->entity instanceof BookChild) {
-            $query->where('book_id', '=', $this->entity->book_id);
-        }
-
-        if ($this->entity->id) {
-            $query->where('id', '!=', $this->entity->id);
-        }
-
-        return $query->count() > 0;
-    }
-}
similarity index 83%
rename from app/Entities/Managers/BookContents.php
rename to app/Entities/Tools/BookContents.php
index 8b8d02c1dd0481539b17dcd2286e963e85581196..8622d5e129486f78112e1b23867de77a4a4b7c0c 100644 (file)
@@ -1,16 +1,17 @@
-<?php namespace BookStack\Entities\Managers;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\BookChild;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+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
      */
@@ -18,7 +19,6 @@ class BookContents
 
     /**
      * BookContents constructor.
-     * @param $book
      */
     public function __construct(Book $book)
     {
@@ -36,16 +36,16 @@ class BookContents
             ->where('chapter_id', '=', 0)->max('priority');
         $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
             ->max('priority');
+
         return max($maxChapter, $maxPage, 1);
     }
 
     /**
      * Get the contents as a sorted collection tree.
-     * TODO - Support $renderPages option
      */
     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');
@@ -54,14 +54,22 @@ class BookContents
         $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
             $chapter = $chapterMap->get($chapter_id);
             if ($chapter) {
-                $chapter->setAttribute('pages', collect($pages)->sortBy($this->bookChildSortFunc()));
+                $chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc()));
             } else {
                 $lonePages = $lonePages->concat($pages);
             }
         });
 
-        $all->each(function (Entity $entity) {
+        $chapters->whereNull('visible_pages')->each(function (Chapter $chapter) {
+            $chapter->setAttribute('visible_pages', collect([]));
+        });
+
+        $all->each(function (Entity $entity) use ($renderPages) {
             $entity->setRelation('book', $this->book);
+
+            if ($renderPages && $entity->isA('page')) {
+                $entity->html = (new PageContent($entity))->render();
+            }
         });
 
         return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
@@ -77,6 +85,7 @@ class BookContents
             if (isset($entity['draft']) && $entity['draft']) {
                 return -100;
             }
+
             return $entity['priority'] ?? 0;
         };
     }
@@ -84,9 +93,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);
@@ -104,9 +115,10 @@ class BookContents
      *     +"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)
-     *   }
+     *   }.
      *
      * Returns a list of books that were involved in the operation.
+     *
      * @throws SortOperationException
      */
     public function sortUsingMap(Collection $sortMap): Collection
@@ -184,6 +196,7 @@ class BookContents
     /**
      * Get the books involved in a sort.
      * The given sort map should have its models loaded first.
+     *
      * @throws SortOperationException
      */
     protected function getBooksInvolvedInSort(Collection $sortMap): Collection
@@ -196,7 +209,7 @@ class BookContents
         $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
 
         if (count($books) !== count($bookIdsInvolved)) {
-            throw new SortOperationException("Could not find all books requested in sort operation");
+            throw new SortOperationException('Could not find all books requested in sort operation');
         }
 
         return $books;
similarity index 75%
rename from app/Entities/ExportService.php
rename to app/Entities/Tools/ExportFormatter.php
index f945dfbe4afe1d17246ca7119c6e776c4acf0455..05d0ff13466ad81c9de1da4ee60b2373429e6dac 100644 (file)
@@ -1,16 +1,19 @@
-<?php namespace BookStack\Entities;
+<?php
 
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
+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 Exception;
 use SnappyPDF;
 use Throwable;
 
-class ExportService
+class ExportFormatter
 {
-
     protected $imageService;
 
     /**
@@ -24,20 +27,23 @@ class ExportService
     /**
      * 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)
@@ -48,43 +54,49 @@ class ExportService
         });
         $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',
         ])->render();
+
         return $this->htmlToPdf($html);
     }
 
     /**
      * Convert a chapter to a PDF file.
+     *
      * @throws Throwable
      */
     public function chapterToPdf(Chapter $chapter)
@@ -96,8 +108,8 @@ class ExportService
 
         $html = view('chapters.export', [
             'chapter' => $chapter,
-            'pages' => $pages,
-            'format' => 'pdf',
+            'pages'   => $pages,
+            'format'  => 'pdf',
         ])->render();
 
         return $this->htmlToPdf($html);
@@ -105,44 +117,49 @@ class ExportService
 
     /**
      * 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',
         ])->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;
+        $useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
         if ($useWKHTML) {
             $pdf = SnappyPDF::loadHTML($containedHtml);
             $pdf->setOption('print-media-type', true);
         } else {
             $pdf = DomPDF::loadHTML($containedHtml);
         }
+
         return $pdf->output();
     }
 
     /**
      * Bundle of the contents of a html file to be self-contained.
+     *
      * @throws Exception
      */
     protected function containHtml(string $htmlContent): string
     {
         $imageTagsOutput = [];
-        preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
+        preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
 
         // Replace image src with base64 encoded image strings
         if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
@@ -193,6 +210,7 @@ class ExportService
         $text = html_entity_decode($text);
         // Add title
         $text = $page->name . "\n\n" . $text;
+
         return $text;
     }
 
@@ -203,9 +221,10 @@ class ExportService
     {
         $text = $chapter->name . "\n\n";
         $text .= $chapter->description . "\n\n";
-        foreach ($chapter->pages as $page) {
+        foreach ($chapter->getVisiblePages() as $page) {
             $text .= $this->pageToPlainText($page);
         }
+
         return $text;
     }
 
@@ -214,7 +233,7 @@ class ExportService
      */
     public function bookToPlainText(Book $book): string
     {
-        $bookTree = (new BookContents($book))->getTree(false, true);
+        $bookTree = (new BookContents($book))->getTree(false, false);
         $text = $book->name . "\n\n";
         foreach ($bookTree as $bookChild) {
             if ($bookChild->isA('chapter')) {
@@ -223,6 +242,51 @@ class ExportService
                 $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);
+    }
+}
diff --git a/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php b/app/Entities/Tools/Markdown/CustomStrikeThroughExtension.php
new file mode 100644 (file)
index 0000000..a8ccfc4
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
+
+use League\CommonMark\ConfigurableEnvironmentInterface;
+use League\CommonMark\Extension\ExtensionInterface;
+use League\CommonMark\Extension\Strikethrough\Strikethrough;
+use League\CommonMark\Extension\Strikethrough\StrikethroughDelimiterProcessor;
+
+class CustomStrikeThroughExtension implements ExtensionInterface
+{
+    public function register(ConfigurableEnvironmentInterface $environment)
+    {
+        $environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
+        $environment->addInlineRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
+    }
+}
diff --git a/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php b/app/Entities/Tools/Markdown/CustomStrikethroughRenderer.php
new file mode 100644 (file)
index 0000000..ca9f434
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
+
+use League\CommonMark\ElementRendererInterface;
+use League\CommonMark\Extension\Strikethrough\Strikethrough;
+use League\CommonMark\HtmlElement;
+use League\CommonMark\Inline\Element\AbstractInline;
+use League\CommonMark\Inline\Renderer\InlineRendererInterface;
+
+/**
+ * This is a somewhat clone of the League\CommonMark\Extension\Strikethrough\StrikethroughRender
+ * class but modified slightly to use <s> HTML tags instead of <del> in order to
+ * match front-end markdown-it rendering.
+ */
+class CustomStrikethroughRenderer implements InlineRendererInterface
+{
+    public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
+    {
+        if (!($inline instanceof Strikethrough)) {
+            throw new \InvalidArgumentException('Incompatible inline type: ' . get_class($inline));
+        }
+
+        return new HtmlElement('s', $inline->getData('attributes', []), $htmlRenderer->renderInlines($inline->children()));
+    }
+}
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..f70abd9
--- /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->visible_pages ?? [];
+            $flatOrdered = $flatOrdered->concat($childPages);
+        }
+
+        return $flatOrdered;
+    }
+}
similarity index 53%
rename from app/Entities/Managers/PageContent.php
rename to app/Entities/Tools/PageContent.php
index 36bc2445c33caeb015b7cf6acd847d8fe6eb0fa0..661c554da4809c799d38f2aba8321756ca992019 100644 (file)
@@ -1,14 +1,27 @@
-<?php namespace BookStack\Entities\Managers;
-
-use BookStack\Entities\Page;
+<?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\Util\HtmlContentFilter;
 use DOMDocument;
-use DOMElement;
 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;
+use League\CommonMark\Extension\TaskList\TaskListExtension;
 
 class PageContent
 {
-
     protected $page;
 
     /**
@@ -24,38 +37,121 @@ class PageContent
      */
     public function setNewHTML(string $html)
     {
+        $html = $this->extractBase64Images($this->page, $html);
+        $this->page->html = $this->formatHtml($html);
+        $this->page->text = $this->toPlainText();
+        $this->page->markdown = '';
+    }
+
+    /**
+     * Update the content of the page with new provided Markdown content.
+     */
+    public function setNewMarkdown(string $markdown)
+    {
+        $this->page->markdown = $markdown;
+        $html = $this->markdownToHtml($markdown);
         $this->page->html = $this->formatHtml($html);
         $this->page->text = $this->toPlainText();
     }
 
+    /**
+     * Convert the given Markdown content to a HTML string.
+     */
+    protected function markdownToHtml(string $markdown): string
+    {
+        $environment = Environment::createCommonMarkEnvironment();
+        $environment->addExtension(new TableExtension());
+        $environment->addExtension(new TaskListExtension());
+        $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.
+     */
+    public function extractBase64Images(Page $page, 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);
+        $imageRepo = app()->make(ImageRepo::class);
+        $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+
+        // Get all img elements with image data blobs
+        $imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
+        foreach ($imageNodes as $imageNode) {
+            $imageSrc = $imageNode->getAttribute('src');
+            [$dataDefinition, $base64ImageData] = explode(',', $imageSrc, 2);
+            $extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png');
+
+            // Validate extension
+            if (!in_array($extension, $allowedExtensions)) {
+                $imageNode->setAttribute('src', '');
+                continue;
+            }
+
+            // Save image from data with a random name
+            $imageName = 'embedded-image-' . Str::random(8) . '.' . $extension;
+
+            try {
+                $image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id);
+                $imageNode->setAttribute('src', $image->url);
+            } catch (ImageUploadException $exception) {
+                $imageNode->setAttribute('src', '');
+            }
+        }
+
+        // Generate inner html as a string
+        $html = '';
+        foreach ($childNodes as $childNode) {
+            $html .= $doc->saveHTML($childNode);
+        }
+
+        return $html;
+    }
+
     /**
      * 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;
+        $xPath = new DOMXPath($doc);
 
         // Set ids on top-level nodes
         $idMap = [];
         foreach ($childNodes as $index => $childNode) {
-            $this->setUniqueId($childNode, $idMap);
+            [$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
+            if ($newId && $newId !== $oldId) {
+                $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
+            }
         }
 
         // Ensure no duplicate ids within child items
-        $xPath = new DOMXPath($doc);
         $idElems = $xPath->query('//body//*//*[@id]');
         foreach ($idElems as $domElem) {
-            $this->setUniqueId($domElem, $idMap);
+            [$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
+            if ($newId && $newId !== $oldId) {
+                $this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
+            }
         }
 
         // Generate inner html as a string
@@ -67,23 +163,35 @@ class PageContent
         return $html;
     }
 
+    /**
+     * Update the all links to the $old location to instead point to $new.
+     */
+    protected function updateLinks(DOMXPath $xpath, string $old, string $new)
+    {
+        $old = str_replace('"', '', $old);
+        $matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]');
+        foreach ($matchingLinks as $domElem) {
+            $domElem->setAttribute('href', $new);
+        }
+    }
+
     /**
      * Set a unique id on the given DOMElement.
      * A map for existing ID's should be passed in to check for current existence.
-     * @param DOMElement $element
-     * @param array $idMap
+     * Returns a pair of strings in the format [old_id, new_id].
      */
-    protected function setUniqueId($element, array &$idMap)
+    protected function setUniqueId(\DOMNode $element, array &$idMap): array
     {
         if (get_class($element) !== 'DOMElement') {
-            return;
+            return ['', ''];
         }
 
-        // Overwrite id if not a BookStack custom id
+        // Stop if there's an existing valid id that has not already been used.
         $existingId = $element->getAttribute('id');
         if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
             $idMap[$existingId] = true;
-            return;
+
+            return [$existingId, $existingId];
         }
 
         // Create an unique id for the element
@@ -100,6 +208,8 @@ class PageContent
 
         $element->setAttribute('id', $newId);
         $idMap[$newId] = true;
+
+        return [$existingId, $newId];
     }
 
     /**
@@ -108,18 +218,19 @@ class PageContent
     protected function toPlainText(): string
     {
         $html = $this->render(true);
-        return strip_tags($html);
+
+        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 = $this->escapeScripts($content);
+            $content = HtmlContentFilter::removeScripts($content);
         }
 
         if ($blankIncludes) {
@@ -132,7 +243,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
     {
@@ -140,11 +251,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) : [];
     }
@@ -161,9 +270,9 @@ class PageContent
 
             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;
@@ -173,6 +282,7 @@ class PageContent
         $levelChange = ($tree->pluck('level')->min() - 1);
         $tree = $tree->map(function ($header) use ($levelChange) {
             $header['level'] -= ($levelChange);
+
             return $header;
         });
 
@@ -182,7 +292,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);
     }
@@ -190,7 +300,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);
@@ -206,6 +316,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);
@@ -226,16 +337,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'));
+        $doc = $this->loadDocumentFromHtml($page->html);
 
         // Search included content for the id given and blank out if not exists.
         $matchingElem = $doc->getElementById($sectionId);
@@ -260,45 +368,15 @@ class PageContent
     }
 
     /**
-     * Escape script tags within HTML content.
+     * Create and load a DOMDocument from the given html content.
      */
-    protected function escapeScripts(string $html) : string
+    protected function loadDocumentFromHtml(string $html): DOMDocument
     {
-        if (empty($html)) {
-            return $html;
-        }
-
         libxml_use_internal_errors(true);
         $doc = new DOMDocument();
+        $html = '<body>' . $html . '</body>';
         $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
-        $xPath = new DOMXPath($doc);
 
-        // Remove standard script tags
-        $scriptElems = $xPath->query('//script');
-        foreach ($scriptElems as $scriptElem) {
-            $scriptElem->parentNode->removeChild($scriptElem);
-        }
-
-        // Remove data or JavaScript iFrames
-        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
-        foreach ($badIframes as $badIframe) {
-            $badIframe->parentNode->removeChild($badIframe);
-        }
-
-        // Remove 'on*' attributes
-        $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
-        foreach ($onAttributes as $attr) {
-            /** @var \DOMAttr $attr*/
-            $attrName = $attr->nodeName;
-            $attr->parentNode->removeAttribute($attrName);
-        }
-
-        $html = '';
-        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
-        foreach ($topElems as $child) {
-            $html .= $doc->saveHTML($child);
-        }
-
-        return $html;
+        return $doc;
     }
 }
similarity index 65%
rename from app/Entities/Managers/PageEditActivity.php
rename to app/Entities/Tools/PageEditActivity.php
index cebbf8720f12a0ac7d66b8cc9e67dae3a5719db7..f23506a8c138ebb5be2e9020f8b3dd97fb0a0603 100644 (file)
@@ -1,13 +1,14 @@
-<?php namespace BookStack\Entities\Managers;
+<?php
 
-use BookStack\Entities\Page;
-use BookStack\Entities\PageRevision;
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use Carbon\Carbon;
 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,42 @@ 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 = $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]);
         $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 +78,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/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php
new file mode 100644 (file)
index 0000000..4e83517
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
+use BookStack\Facades\Activity;
+use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
+
+class PermissionsUpdater
+{
+    /**
+     * Update an entities permissions from a permission form submit request.
+     */
+    public function updateFromPermissionsForm(Entity $entity, Request $request)
+    {
+        $restricted = $request->get('restricted') === 'true';
+        $permissions = $request->get('restrictions', null);
+        $ownerId = $request->get('owned_by', null);
+
+        $entity->restricted = $restricted;
+        $entity->permissions()->delete();
+
+        if (!is_null($permissions)) {
+            $entityPermissionData = $this->formatPermissionsFromRequestToEntityPermissions($permissions);
+            $entity->permissions()->createMany($entityPermissionData);
+        }
+
+        if (!is_null($ownerId)) {
+            $this->updateOwnerFromId($entity, intval($ownerId));
+        }
+
+        $entity->save();
+        $entity->rebuildPermissions();
+
+        Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
+    }
+
+    /**
+     * Update the owner of the given entity.
+     * Checks the user exists in the system first.
+     * Does not save the model, just updates it.
+     */
+    protected function updateOwnerFromId(Entity $entity, int $newOwnerId)
+    {
+        $newOwner = User::query()->find($newOwnerId);
+        if (!is_null($newOwner)) {
+            $entity->owned_by = $newOwner->id;
+        }
+    }
+
+    /**
+     * Format permissions provided from a permission form to be
+     * EntityPermission data.
+     */
+    protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
+    {
+        return collect($permissions)->flatMap(function ($restrictions, $roleId) {
+            return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
+                return [
+                    'role_id' => $roleId,
+                    'action'  => strtolower($action),
+                ];
+            });
+        });
+    }
+}
diff --git a/app/Entities/Tools/SearchIndex.php b/app/Entities/Tools/SearchIndex.php
new file mode 100644 (file)
index 0000000..cc0b32d
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\SearchTerm;
+use Illuminate\Support\Collection;
+
+class SearchIndex
+{
+    /**
+     * @var SearchTerm
+     */
+    protected $searchTerm;
+
+    /**
+     * @var EntityProvider
+     */
+    protected $entityProvider;
+
+    public function __construct(SearchTerm $searchTerm, 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);
+    }
+
+    /**
+     * Index multiple Entities at once.
+     *
+     * @param Entity[] $entities
+     */
+    protected 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;
+            }
+        }
+
+        $chunkedTerms = array_chunk($terms, 500);
+        foreach ($chunkedTerms as $termChunk) {
+            $this->searchTerm->newQuery()->insert($termChunk);
+        }
+    }
+
+    /**
+     * Delete and re-index the terms for all entities in the system.
+     */
+    public function indexAllEntities()
+    {
+        $this->searchTerm->newQuery()->truncate();
+
+        foreach ($this->entityProvider->all() as $entityModel) {
+            $selectFields = ['id', 'name', $entityModel->textField];
+            $entityModel->newQuery()
+                ->withTrashed()
+                ->select($selectFields)
+                ->chunk(1000, function (Collection $entities) {
+                    $this->indexEntities($entities->all());
+                });
+        }
+    }
+
+    /**
+     * Delete related Entity search terms.
+     */
+    public function deleteEntityTerms(Entity $entity)
+    {
+        $entity->searchTerms()->delete();
+    }
+
+    /**
+     * Create a scored term array from the given text.
+     */
+    protected function generateTermArrayFromText(string $text, int $scoreAdjustment = 1): array
+    {
+        $tokenMap = []; // {TextToken => OccurrenceCount}
+        $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
+        $token = strtok($text, $splitChars);
+
+        while ($token !== false) {
+            if (!isset($tokenMap[$token])) {
+                $tokenMap[$token] = 0;
+            }
+            $tokenMap[$token]++;
+            $token = strtok($splitChars);
+        }
+
+        $terms = [];
+        foreach ($tokenMap as $token => $count) {
+            $terms[] = [
+                'term'  => $token,
+                'score' => $count * $scoreAdjustment,
+            ];
+        }
+
+        return $terms;
+    }
+}
similarity index 93%
rename from app/Entities/SearchOptions.php
rename to app/Entities/Tools/SearchOptions.php
index a121bd7939cbc6eb4e6ccd18e96e21dcf1e7f992..7913e096979e63174bfe6a36e3e104e31f8c9776 100644 (file)
@@ -1,10 +1,11 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use Illuminate\Http\Request;
 
 class SearchOptions
 {
-
     /**
      * @var array
      */
@@ -35,6 +36,7 @@ class SearchOptions
         foreach ($decoded as $type => $value) {
             $instance->$type = $value;
         }
+
         return $instance;
     }
 
@@ -67,6 +69,7 @@ class SearchOptions
         if (isset($inputs['types']) && count($inputs['types']) < 4) {
             $instance->filters['type'] = implode('|', $inputs['types']);
         }
+
         return $instance;
     }
 
@@ -77,15 +80,15 @@ class SearchOptions
     {
         $terms = [
             'searches' => [],
-            'exacts' => [],
-            'tags' => [],
-            'filters' => []
+            'exacts'   => [],
+            'tags'     => [],
+            'filters'  => [],
         ];
 
         $patterns = [
-            'exacts' => '/"(.*?)"/',
-            'tags' => '/\[(.*?)\]/',
-            'filters' => '/\{(.*?)\}/'
+            'exacts'  => '/"(.*?)"/',
+            'tags'    => '/\[(.*?)\]/',
+            'filters' => '/\{(.*?)\}/',
         ];
 
         // Parse special terms
@@ -137,5 +140,4 @@ class SearchOptions
 
         return $string;
     }
-
-}
\ No newline at end of file
+}
similarity index 63%
rename from app/Entities/SearchService.php
rename to app/Entities/Tools/SearchRunner.php
index 11b731cd0153591e2cfd7b6b71f88504f088cd92..8e18408bd70b10864dbaea09fac3f8c902cd638c 100644 (file)
@@ -1,6 +1,11 @@
-<?php namespace BookStack\Entities;
+<?php
+
+namespace BookStack\Entities\Tools;
 
 use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\User;
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
 use Illuminate\Database\Connection;
 use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
 use Illuminate\Database\Query\Builder;
@@ -8,13 +13,8 @@ use Illuminate\Database\Query\JoinClause;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Str;
 
-class SearchService
+class SearchRunner
 {
-    /**
-     * @var SearchTerm
-     */
-    protected $searchTerm;
-
     /**
      * @var EntityProvider
      */
@@ -30,32 +30,20 @@ class SearchService
      */
     protected $permissionService;
 
-
     /**
-     * Acceptable operators to be used in a query
+     * Acceptable operators to be used in a query.
+     *
      * @var array
      */
     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
 
-    /**
-     * SearchService constructor.
-     */
-    public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
+    public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
     {
-        $this->searchTerm = $searchTerm;
         $this->entityProvider = $entityProvider;
         $this->db = $db;
         $this->permissionService = $permissionService;
     }
 
-    /**
-     * Set the database connection
-     */
-    public function setConnection(Connection $connection)
-    {
-        $this->db = $connection;
-    }
-
     /**
      * Search all entities in the system.
      * The provided count is for each entity to search,
@@ -68,7 +56,7 @@ class SearchService
 
         if ($entityType !== 'all') {
             $entityTypesToSearch = $entityType;
-        } else if (isset($searchOpts->filters['type'])) {
+        } elseif (isset($searchOpts->filters['type'])) {
             $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
         }
 
@@ -90,16 +78,15 @@ class SearchService
         }
 
         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
     {
@@ -115,16 +102,18 @@ class SearchService
             $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
             $results = $results->merge($search);
         }
+
         return $results->sortByDesc('score')->take(20);
     }
 
     /**
-     * Search a book 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();
+
         return $pages->sortByDesc('score');
     }
 
@@ -132,21 +121,23 @@ class SearchService
      * 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[]
      */
-    public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
+    protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
     {
         $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
         if ($getCount) {
             return $query->count();
         }
 
-        $query = $query->skip(($page-1) * $count)->take($count);
+        $query = $query->skip(($page - 1) * $count)->take($count);
+
         return $query->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
     {
@@ -155,28 +146,25 @@ class SearchService
 
         // Handle normal search terms
         if (count($searchOpts->searches) > 0) {
-            $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
+            $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 .'%');
+                    $query->orWhere('term', 'like', $inputTerm . '%');
                 }
             })->groupBy('entity_type', 'entity_id');
-            $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
+            $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');
+            })->selectRaw($entity->getTable() . '.*, s.score')->orderBy('score', 'desc');
             $entitySelect->mergeBindings($subQuery);
         }
 
         // Handle exact term matching
-        if (count($searchOpts->exacts) > 0) {
-            $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
-                foreach ($searchOpts->exacts as $inputTerm) {
-                    $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
-                        $query->where('name', 'like', '%'.$inputTerm .'%')
-                            ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
-                    });
-                }
+        foreach ($searchOpts->exacts as $inputTerm) {
+            $entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
+                $query->where('name', 'like', '%' . $inputTerm . '%')
+                    ->orWhere($entity->textField, 'like', '%' . $inputTerm . '%');
             });
         }
 
@@ -193,7 +181,7 @@ class SearchService
             }
         }
 
-        return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
+        return $this->permissionService->enforceEntityRestrictions($entity, $entitySelect, $action);
     }
 
     /**
@@ -205,6 +193,7 @@ class SearchService
         foreach ($this->queryOperators as $operator) {
             $escapedOperators[] = preg_quote($operator);
         }
+
         return join('|', $escapedOperators);
     }
 
@@ -213,7 +202,7 @@ class SearchService
      */
     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] : '';
@@ -236,109 +225,13 @@ class SearchService
                 $query->where('name', '=', $tagName);
             }
         });
-        return $query;
-    }
-
-    /**
-     * 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);
-    }
-
-    /**
-     * Index multiple Entities at once
-     * @param \BookStack\Entities\Entity[] $entities
-     */
-    protected function indexEntities($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;
-            }
-        }
-
-        $chunkedTerms = array_chunk($terms, 500);
-        foreach ($chunkedTerms as $termChunk) {
-            $this->searchTerm->newQuery()->insert($termChunk);
-        }
-    }
 
-    /**
-     * Delete and re-index the terms for all entities in the system.
-     */
-    public function indexAllEntities()
-    {
-        $this->searchTerm->truncate();
-
-        foreach ($this->entityProvider->all() as $entityModel) {
-            $selectFields = ['id', 'name', $entityModel->textField];
-            $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) {
-                $this->indexEntities($entities);
-            });
-        }
-    }
-
-    /**
-     * Delete related Entity search terms.
-     * @param Entity $entity
-     */
-    public function deleteEntityTerms(Entity $entity)
-    {
-        $entity->searchTerms()->delete();
-    }
-
-    /**
-     * Create a scored term array from the given text.
-     * @param $text
-     * @param float|int $scoreAdjustment
-     * @return array
-     */
-    protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
-    {
-        $tokenMap = []; // {TextToken => OccurrenceCount}
-        $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
-        $token = strtok($text, $splitChars);
-
-        while ($token !== false) {
-            if (!isset($tokenMap[$token])) {
-                $tokenMap[$token] = 0;
-            }
-            $tokenMap[$token]++;
-            $token = strtok($splitChars);
-        }
-
-        $terms = [];
-        foreach ($tokenMap as $token => $count) {
-            $terms[] = [
-                'term' => $token,
-                'score' => $count * $scoreAdjustment
-            ];
-        }
-        return $terms;
+        return $query;
     }
 
-
-
-
     /**
-     * Custom entity search filters
+     * Custom entity search filters.
      */
-
     protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input)
     {
         try {
@@ -381,29 +274,34 @@ class SearchService
 
     protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
     {
-        if (!is_numeric($input) && $input !== 'me') {
-            return;
-        }
-        if ($input === 'me') {
-            $input = user()->id;
+        $userSlug = $input === 'me' ? user()->slug : trim($input);
+        $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
+        if ($user) {
+            $query->where('created_by', '=', $user->id);
         }
-        $query->where('created_by', '=', $input);
     }
 
     protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
     {
-        if (!is_numeric($input) && $input !== 'me') {
-            return;
+        $userSlug = $input === 'me' ? user()->slug : trim($input);
+        $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
+        if ($user) {
+            $query->where('updated_by', '=', $user->id);
         }
-        if ($input === 'me') {
-            $input = user()->id;
+    }
+
+    protected function filterOwnedBy(EloquentBuilder $query, Entity $model, $input)
+    {
+        $userSlug = $input === 'me' ? user()->slug : trim($input);
+        $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
+        if ($user) {
+            $query->where('owned_by', '=', $user->id);
         }
-        $query->where('updated_by', '=', $input);
     }
 
     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)
@@ -413,7 +311,7 @@ class SearchService
 
     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)
@@ -443,16 +341,14 @@ class SearchService
         }
     }
 
-
     /**
-     * Sorting filter options
+     * Sorting filter options.
      */
-
     protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
     {
         $commentsTable = $this->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 = $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');
 
         $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
     }
similarity index 56%
rename from app/Entities/Managers/EntityContext.php
rename to app/Entities/Tools/ShelfContext.php
index 551cd1a100c142f26cea88d8e86aaa282c768fa0..50d7981716e68c7dd662e982e22b115d64a0b4f2 100644 (file)
@@ -1,29 +1,20 @@
-<?php namespace BookStack\Entities\Managers;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use Illuminate\Session\Store;
+namespace BookStack\Entities\Tools;
 
-class EntityContext
-{
-    protected $session;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 
+class ShelfContext
+{
     protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
 
-    /**
-     * EntityContextManager constructor.
-     */
-    public function __construct(Store $session)
-    {
-        $this->session = $session;
-    }
-
     /**
      * Get the current bookshelf context for the given book.
      */
     public function getContextualShelfForBook(Book $book): ?Bookshelf
     {
-        $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
+        $contextBookshelfId = session()->get($this->KEY_SHELF_CONTEXT_ID, null);
 
         if (!is_int($contextBookshelfId)) {
             return null;
@@ -37,11 +28,10 @@ class EntityContext
 
     /**
      * Store the current contextual shelf ID.
-     * @param int $shelfId
      */
     public function setShelfContext(int $shelfId)
     {
-        $this->session->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
+        session()->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
     }
 
     /**
@@ -49,6 +39,6 @@ class EntityContext
      */
     public function clearShelfContext()
     {
-        $this->session->forget($this->KEY_SHELF_CONTEXT_ID);
+        session()->forget($this->KEY_SHELF_CONTEXT_ID);
     }
 }
diff --git a/app/Entities/Tools/SiblingFetcher.php b/app/Entities/Tools/SiblingFetcher.php
new file mode 100644 (file)
index 0000000..e9dad0e
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+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);
+        $entities = [];
+
+        // Page in chapter
+        if ($entity->isA('page') && $entity->chapter) {
+            $entities = $entity->chapter->getVisiblePages();
+        }
+
+        // Page in book or chapter
+        if (($entity->isA('page') && !$entity->chapter) || $entity->isA('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 ($contextShelf) {
+                $entities = $contextShelf->visibleBooks()->get();
+            } else {
+                $entities = Book::visible()->get();
+            }
+        }
+
+        // Shelve
+        if ($entity->isA('bookshelf')) {
+            $entities = Bookshelf::visible()->get();
+        }
+
+        return $entities;
+    }
+}
diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php
new file mode 100644 (file)
index 0000000..52e5700
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\BookChild;
+use BookStack\Interfaces\Sluggable;
+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.
+     */
+    public function generate(Sluggable $model): string
+    {
+        $slug = $this->formatNameAsSlug($model->name);
+        while ($this->slugInUse($slug, $model)) {
+            $slug .= '-' . Str::random(3);
+        }
+
+        return $slug;
+    }
+
+    /**
+     * Format a name as a url slug.
+     */
+    protected function formatNameAsSlug(string $name): string
+    {
+        $slug = Str::slug($name);
+        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.
+     */
+    protected function slugInUse(string $slug, Sluggable $model): bool
+    {
+        $query = $model->newQuery()->where('slug', '=', $slug);
+
+        if ($model instanceof BookChild) {
+            $query->where('book_id', '=', $model->book_id);
+        }
+
+        if ($model->id) {
+            $query->where('id', '!=', $model->id);
+        }
+
+        return $query->count() > 0;
+    }
+}
diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php
new file mode 100644 (file)
index 0000000..8256927
--- /dev/null
@@ -0,0 +1,348 @@
+<?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\Models\HasCoverImage;
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
+use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\ImageService;
+use Exception;
+use Illuminate\Support\Carbon;
+
+class TrashCan
+{
+    /**
+     * Send a shelf to the recycle bin.
+     */
+    public function softDestroyShelf(Bookshelf $shelf)
+    {
+        Deletion::createForEntity($shelf);
+        $shelf->delete();
+    }
+
+    /**
+     * Send a book to the recycle bin.
+     *
+     * @throws Exception
+     */
+    public function softDestroyBook(Book $book)
+    {
+        Deletion::createForEntity($book);
+
+        foreach ($book->pages as $page) {
+            $this->softDestroyPage($page, false);
+        }
+
+        foreach ($book->chapters as $chapter) {
+            $this->softDestroyChapter($chapter, false);
+        }
+
+        $book->delete();
+    }
+
+    /**
+     * Send a chapter to the recycle bin.
+     *
+     * @throws Exception
+     */
+    public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
+    {
+        if ($recordDelete) {
+            Deletion::createForEntity($chapter);
+        }
+
+        if (count($chapter->pages) > 0) {
+            foreach ($chapter->pages as $page) {
+                $this->softDestroyPage($page, false);
+            }
+        }
+
+        $chapter->delete();
+    }
+
+    /**
+     * Send a page to the recycle bin.
+     *
+     * @throws Exception
+     */
+    public function softDestroyPage(Page $page, bool $recordDelete = true)
+    {
+        if ($recordDelete) {
+            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());
+            }
+            setting()->remove('app-homepage');
+        }
+
+        $page->delete();
+    }
+
+    /**
+     * 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
+    {
+        $count = 0;
+        $pages = $book->pages()->withTrashed()->get();
+        foreach ($pages as $page) {
+            $this->destroyPage($page);
+            $count++;
+        }
+
+        $chapters = $book->chapters()->withTrashed()->get();
+        foreach ($chapters as $chapter) {
+            $this->destroyChapter($chapter);
+            $count++;
+        }
+
+        $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++;
+            }
+        }
+
+        $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);
+        foreach ($page->attachments as $attachment) {
+            $attachmentService->deleteFile($attachment);
+        }
+
+        $page->forceDelete();
+
+        return 1;
+    }
+
+    /**
+     * Get the total counts of those that have been trashed
+     * but not yet fully deleted (In recycle bin).
+     */
+    public function getTrashedCounts(): array
+    {
+        $counts = [];
+
+        /** @var Entity $instance */
+        foreach ((new EntityProvider())->all() as $key => $instance) {
+            $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
+        }
+
+        return $counts;
+    }
+
+    /**
+     * Destroy all items that have pending deletions.
+     *
+     * @throws Exception
+     */
+    public function empty(): int
+    {
+        $deletions = Deletion::all();
+        $deleteCount = 0;
+        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
+    {
+        // We directly load the deletable element here just to ensure it still
+        // exists in the event it has already been destroyed during this request.
+        $entity = $deletion->deletable()->first();
+        $count = 0;
+        if ($entity) {
+            $count = $this->destroyEntity($deletion->deletable);
+        }
+        $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 ($shouldRestore) {
+            $restoreCount = $this->restoreEntity($deletion->deletable);
+        }
+
+        $deletion->delete();
+
+        return $restoreCount;
+    }
+
+    /**
+     * 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
+    {
+        $lifetime = intval(config('app.recycle_bin_lifetime'));
+        if ($lifetime < 0) {
+            return 0;
+        }
+
+        $clearBeforeDate = Carbon::now()->addSeconds(10)->subDays($lifetime);
+        $deleteCount = 0;
+
+        $deletionsToRemove = Deletion::query()->where('created_at', '<', $clearBeforeDate)->get();
+        foreach ($deletionsToRemove as $deletion) {
+            $deleteCount += $this->destroyFromDeletion($deletion);
+        }
+
+        return $deleteCount;
+    }
+
+    /**
+     * Restore an entity so it is essentially un-deleted.
+     * Deletions on restored child elements will be removed during this restoration.
+     */
+    protected function restoreEntity(Entity $entity): int
+    {
+        $count = 1;
+        $entity->restore();
+
+        $restoreAction = function ($entity) use (&$count) {
+            if ($entity->deletions_count > 0) {
+                $entity->deletions()->delete();
+            }
+
+            $entity->restore();
+            $count++;
+        };
+
+        if ($entity instanceof Chapter || $entity instanceof Book) {
+            $entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
+        }
+
+        if ($entity instanceof Book) {
+            $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
+        }
+
+        return $count;
+    }
+
+    /**
+     * Destroy the given entity.
+     *
+     * @throws Exception
+     */
+    protected function destroyEntity(Entity $entity): int
+    {
+        if ($entity instanceof Page) {
+            return $this->destroyPage($entity);
+        }
+        if ($entity instanceof Chapter) {
+            return $this->destroyChapter($entity);
+        }
+        if ($entity instanceof Book) {
+            return $this->destroyBook($entity);
+        }
+        if ($entity instanceof Bookshelf) {
+            return $this->destroyShelf($entity);
+        }
+    }
+
+    /**
+     * Update entity relations to remove or update outstanding connections.
+     */
+    protected function destroyCommonRelations(Entity $entity)
+    {
+        Activity::removeEntity($entity);
+        $entity->views()->delete();
+        $entity->permissions()->delete();
+        $entity->tags()->delete();
+        $entity->comments()->delete();
+        $entity->jointPermissions()->delete();
+        $entity->searchTerms()->delete();
+        $entity->deletions()->delete();
+        $entity->favourites()->delete();
+
+        if ($entity instanceof HasCoverImage && $entity->cover) {
+            $imageService = app()->make(ImageService::class);
+            $imageService->destroy($entity->cover);
+        }
+    }
+}
index cc68ba8cf424c31c4f741e11aea34f9f50177a4e..360370de4109e723ed3446d5ff9de901fcc80556 100644 (file)
@@ -2,6 +2,6 @@
 
 namespace BookStack\Exceptions;
 
-class ApiAuthException extends UnauthorizedException {
-
-}
\ No newline at end of file
+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 57078522b748bbbf905b1836705a4d24c0695bbb..2d40f44769127062ea294a7ba12603cf3ad6b05f 100644 (file)
@@ -3,49 +3,54 @@
 namespace BookStack\Exceptions;
 
 use Exception;
-use Illuminate\Auth\Access\AuthorizationException;
 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;
 
 class Handler extends ExceptionHandler
 {
     /**
-     * A list of the exception types that should not be reported.
+     * A list of the exception types that are not reported.
      *
      * @var array
      */
     protected $dontReport = [
-        AuthorizationException::class,
-        HttpException::class,
-        ModelNotFoundException::class,
-        ValidationException::class,
         NotFoundException::class,
     ];
 
+    /**
+     * A list of the inputs that are never flashed for validation exceptions.
+     *
+     * @var array
+     */
+    protected $dontFlash = [
+        'password',
+        'password_confirmation',
+    ];
+
     /**
      * Report or log an exception.
-     * This is a great spot to send exceptions to Sentry, Bugsnag, etc.
      *
-     * @param  \Exception $e
-     * @return mixed
+     * @param Exception $exception
+     *
      * @throws Exception
+     *
+     * @return void
      */
-    public function report(Exception $e)
+    public function report(Exception $exception)
     {
-        return parent::report($e);
+        parent::report($exception);
     }
 
     /**
      * 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)
@@ -54,29 +59,6 @@ class Handler extends ExceptionHandler
             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);
     }
 
@@ -103,7 +85,7 @@ class Handler extends ExceptionHandler
         $responseData = [
             'error' => [
                 'message' => $e->getMessage(),
-            ]
+            ],
         ];
 
         if ($e instanceof ValidationException) {
@@ -112,43 +94,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.
-     * @param Exception $e
-     * @param $type
-     * @return bool
-     */
-    protected function isExceptionType(Exception $e, $type)
-    {
-        do {
-            if (is_a($e, $type)) {
-                return true;
-            }
-        } while ($e = $e->getPrevious());
-        return false;
-    }
 
-    /**
-     * Get original exception message.
-     * @param Exception $e
-     * @return string
-     */
-    protected function getOriginalMessage(Exception $e)
-    {
-        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)
@@ -163,8 +118,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 6314533ce11d2fdf0afab4c11131ea4f5af2e68a..e037fcb8e9041dd7bcb4f8115af350b299cb148d 100644 (file)
@@ -1,10 +1,11 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 use Exception;
 
 class JsonDebugException extends Exception
 {
-
     protected $data;
 
     /**
@@ -22,4 +23,4 @@ class JsonDebugException extends Exception
     {
         return response()->json($this->data);
     }
-}
\ No newline at end of file
+}
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 af85ee4c9a2ffe3768748758946cdade32dc29b5..016ee597f5528adeed9e40095bb3138777a30de0 100644 (file)
@@ -1,11 +1,11 @@
-<?php namespace BookStack\Exceptions;
+<?php
+
+namespace BookStack\Exceptions;
 
 class NotFoundException extends PrettyException
 {
-
     /**
      * NotFoundException constructor.
-     * @param string $message
      */
     public function __construct($message = 'Item not found')
     {
index 4f810596099cf2132093677b571ce40bf44a5aa8..ef9a441010f687ba66325fb061a1237ae253e737 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);
+    }
 }
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..33c1471f2849b69fd0ee667ca81d2e372700af02 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
 {
-
 }
index 8f91217f62bbf8188bf98bbb7a25eead371f9b33..ade9e47d21672b0cb52e42eb0d27202a52e363ff 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Exceptions;
+<?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..d10a6da
--- /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 525b431c7b45de7538e37526f31cf5f4cd9764de..5c73ca02c6843f99aba269e12c06ffbcd45ceb57 100644 (file)
@@ -6,7 +6,6 @@ use Exception;
 
 class UnauthorizedException extends Exception
 {
-
     /**
      * ApiAuthException constructor.
      */
@@ -14,4 +13,4 @@ class UnauthorizedException extends Exception
     {
         parent::__construct($message, $code);
     }
-}
\ No newline at end of file
+}
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
 {
index 30e4b785fdb0bdb6712fcc8b448070bd23ba7345..76493efd79adfd2061cc7ba38580bee9548b3d78 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Facades;
+<?php
+
+namespace BookStack\Facades;
 
 use Illuminate\Support\Facades\Facade;
 
index c552d7cdb03d59ca512abe9419c0b554f5596be6..74cbe46fef330eec9874157d3b5d58f10fafb609 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Facades;
+<?php
+
+namespace BookStack\Facades;
 
 use Illuminate\Support\Facades\Facade;
 
diff --git a/app/Facades/Setting.php b/app/Facades/Setting.php
deleted file mode 100644 (file)
index 80feef8..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<?php namespace BookStack\Facades;
-
-use Illuminate\Support\Facades\Facade;
-
-class Setting extends Facade
-{
-    /**
-     * Get the registered name of the component.
-     *
-     * @return string
-     */
-    protected static function getFacadeAccessor()
-    {
-        return 'setting';
-    }
-}
similarity index 59%
rename from app/Facades/Images.php
rename to app/Facades/Theme.php
index fdbd35a99ad321a680a6619dc22c8db797e9f14c..79867ae6d6aca53a7cd2698e0a42b61e06ccaa42 100644 (file)
@@ -1,8 +1,11 @@
-<?php namespace BookStack\Facades;
+<?php
 
+namespace BookStack\Facades;
+
+use BookStack\Theming\ThemeService;
 use Illuminate\Support\Facades\Facade;
 
-class Images extends Facade
+class Theme extends Facade
 {
     /**
      * Get the registered name of the component.
@@ -11,6 +14,6 @@ class Images extends Facade
      */
     protected static function getFacadeAccessor()
     {
-        return 'images';
+        return ThemeService::class;
     }
 }
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 65a5bb99f6ca3bfc6f99e4e0137d4e6029d91ff3..fc9788b06e340d405eb73b9d56fd5c1fb4290c5f 100644 (file)
@@ -1,13 +1,14 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Api\ListingResponseBuilder;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Http\JsonResponse;
 
-class ApiController extends Controller
+abstract class ApiController extends Controller
 {
-
     protected $rules = [];
 
     /**
@@ -17,6 +18,7 @@ class ApiController extends Controller
     protected function apiListingResponse(Builder $query, array $fields): JsonResponse
     {
         $listing = new ListingResponseBuilder($query, request(), $fields);
+
         return $listing->toResponse();
     }
 
@@ -27,4 +29,4 @@ class ApiController extends Controller
     {
         return $this->rules;
     }
-}
\ No newline at end of file
+}
index 84ddd521567ca13ebdada94d4722dee2e56ba307..a1453e7684bb0c4bb7e55534bf1eb2f8e268effe 100644 (file)
@@ -1,18 +1,19 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
+
+namespace BookStack\Http\Controllers\Api;
 
 use BookStack\Api\ApiDocsGenerator;
-use Cache;
-use Illuminate\Support\Collection;
 
 class ApiDocsController extends ApiController
 {
-
     /**
      * Load the docs page for the API.
      */
     public function display()
     {
-        $docs = $this->getDocs();
+        $docs = ApiDocsGenerator::generateConsideringCache();
+        $this->setPageTitle(trans('settings.users_api_tokens_docs'));
+
         return view('api-docs.index', [
             'docs' => $docs,
         ]);
@@ -21,27 +22,10 @@ class ApiDocsController extends ApiController
     /**
      * Show a JSON view of the API docs data.
      */
-    public function json() {
-        $docs = $this->getDocs();
-        return response()->json($docs);
-    }
-
-    /**
-     * Get the base docs data.
-     * Checks and uses the system cache for quick re-fetching.
-     */
-    protected function getDocs(): Collection
+    public function json()
     {
-        $appVersion = trim(file_get_contents(base_path('version')));
-        $cacheKey = 'api-docs::' . $appVersion;
-        if (Cache::has($cacheKey) && config('app.env') === 'production') {
-            $docs = Cache::get($cacheKey);
-        } else {
-            $docs = (new ApiDocsGenerator())->generate();
-            Cache::put($cacheKey, $docs, 60*24);
-        }
+        $docs = ApiDocsGenerator::generateConsideringCache();
 
-        return $docs;
+        return response()->json($docs);
     }
-
 }
index 8333eba3a1d3779431dbbffa39e4e9abd49837d2..abe23f45dbef180506055a57b6aa21305a1d3565 100644 (file)
@@ -1,34 +1,29 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
 
-use BookStack\Entities\Book;
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BookRepo;
-use BookStack\Exceptions\NotifyException;
-use BookStack\Facades\Activity;
-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',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'tags' => 'array',
+            'tags'        => 'array',
         ],
         'update' => [
-            'name' => 'string|min:1|max:255',
+            'name'        => 'string|min:1|max:255',
             'description' => 'string|max:1000',
-            'tags' => 'array',
+            'tags'        => 'array',
         ],
     ];
 
-    /**
-     * BooksApiController constructor.
-     */
     public function __construct(BookRepo $bookRepo)
     {
         $this->bookRepo = $bookRepo;
@@ -40,13 +35,15 @@ 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', 'image_id',
+            'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
         ]);
     }
 
     /**
      * Create a new book in the system.
+     *
      * @throws ValidationException
      */
     public function create(Request $request)
@@ -55,7 +52,6 @@ class BookApiController extends ApiController
         $requestData = $this->validate($request, $this->rules['create']);
 
         $book = $this->bookRepo->create($requestData);
-        Activity::add($book, 'book_create', $book->id);
 
         return response()->json($book);
     }
@@ -65,12 +61,14 @@ class BookApiController extends ApiController
      */
     public function read(string $id)
     {
-        $book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy'])->findOrFail($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)
@@ -80,15 +78,15 @@ class BookApiController extends ApiController
 
         $requestData = $this->validate($request, $this->rules['update']);
         $book = $this->bookRepo->update($book, $requestData);
-        Activity::add($book, 'book_update', $book->id);
 
         return response()->json($book);
     }
 
     /**
-     * Delete a single book from the system.
-     * @throws NotifyException
-     * @throws BindingResolutionException
+     * Delete a single book.
+     * This will typically send the book to the recycle bin.
+     *
+     * @throws \Exception
      */
     public function delete(string $id)
     {
@@ -96,8 +94,7 @@ class BookApiController extends ApiController
         $this->checkOwnablePermission('book-delete', $book);
 
         $this->bookRepo->destroy($book);
-        Activity::addMessage('book_delete', $book->name);
 
         return response('', 204);
     }
-}
\ No newline at end of file
+}
index 31fe5250fd7e3f14d7d60b8ff51a5d4bc4c241fd..028bc3a817ebf726b358ca0f7b8d47f393695bf8 100644 (file)
@@ -1,44 +1,44 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Repos\BookRepo;
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ExportFormatter;
 use Throwable;
 
 class BookExportApiController extends ApiController
 {
-    protected $bookRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
-    /**
-     * BookExportController constructor.
-     */
-    public function __construct(BookRepo $bookRepo, ExportService $exportService)
+    public function __construct(ExportFormatter $exportFormatter)
     {
-        $this->bookRepo = $bookRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $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->exportService->bookToPdf($book);
+        $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->exportService->bookToContainedHtml($book);
+        $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
+
         return $this->downloadResponse($htmlContent, $book->slug . '.html');
     }
 
@@ -48,7 +48,19 @@ class BookExportApiController extends ApiController
     public function exportPlainText(int $id)
     {
         $book = Book::visible()->findOrFail($id);
-        $textContent = $this->exportService->bookToPlainText($book);
+        $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 14b5e053b9ec42b8fc9c18dc0c40119be5dcbabf..c29e5b0ae4c2d656843a6789503b619a278431aa 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
 
-use BookStack\Facades\Activity;
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Repos\BookshelfRepo;
-use BookStack\Entities\Bookshelf;
 use Exception;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Http\Request;
@@ -10,7 +11,6 @@ use Illuminate\Validation\ValidationException;
 
 class BookshelfApiController extends ApiController
 {
-
     /**
      * @var BookshelfRepo
      */
@@ -18,20 +18,19 @@ class BookshelfApiController extends ApiController
 
     protected $rules = [
         'create' => [
-            'name' => 'required|string|max:255',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'books' => 'array',
+            'books'       => 'array',
         ],
         'update' => [
-            'name' => 'string|min:1|max:255',
+            'name'        => 'string|min:1|max:255',
             'description' => 'string|max:1000',
-            'books' => 'array',
+            'books'       => 'array',
         ],
     ];
 
     /**
      * BookshelfApiController constructor.
-     * @param BookshelfRepo $bookshelfRepo
      */
     public function __construct(BookshelfRepo $bookshelfRepo)
     {
@@ -44,8 +43,9 @@ 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', 'image_id',
+            'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
         ]);
     }
 
@@ -53,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)
@@ -63,7 +64,6 @@ class BookshelfApiController extends ApiController
         $bookIds = $request->get('books', []);
         $shelf = $this->bookshelfRepo->create($requestData, $bookIds);
 
-        Activity::add($shelf, 'bookshelf_create', $shelf->id);
         return response()->json($shelf);
     }
 
@@ -73,11 +73,12 @@ class BookshelfApiController extends ApiController
     public function read(string $id)
     {
         $shelf = Bookshelf::visible()->with([
-            'tags', 'cover', 'createdBy', 'updatedBy',
+            'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
             'books' => function (BelongsToMany $query) {
                 $query->visible()->get(['id', 'name', 'slug']);
-            }
+            },
         ])->findOrFail($id);
+
         return response()->json($shelf);
     }
 
@@ -86,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,19 +96,17 @@ class BookshelfApiController extends ApiController
         $this->checkOwnablePermission('bookshelf-update', $shelf);
 
         $requestData = $this->validate($request, $this->rules['update']);
-
         $bookIds = $request->get('books', null);
 
         $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
-        Activity::add($shelf, 'bookshelf_update', $shelf->id);
 
         return response()->json($shelf);
     }
 
-
-
     /**
-     * Delete a single shelf from the system.
+     * Delete a single shelf.
+     * This will typically send the shelf to the recycle bin.
+     *
      * @throws Exception
      */
     public function delete(string $id)
@@ -115,8 +115,7 @@ class BookshelfApiController extends ApiController
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
         $this->bookshelfRepo->destroy($shelf);
-        Activity::addMessage('bookshelf_delete', $shelf->name);
 
         return response('', 204);
     }
-}
\ No newline at end of file
+}
index 50aa8834ec13ea7cc23bbf158a740f7dc16e8fb0..13b3f9821171c77f664f94e7349f4a860abe76d3 100644 (file)
@@ -1,9 +1,10 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+namespace BookStack\Http\Controllers\Api;
+
+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;
 
@@ -13,16 +14,16 @@ class ChapterApiController extends ApiController
 
     protected $rules = [
         'create' => [
-            'book_id' => 'required|integer',
-            'name' => 'required|string|max:255',
+            'book_id'     => 'required|integer',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'tags' => 'array',
+            'tags'        => 'array',
         ],
         'update' => [
-            'book_id' => 'integer',
-            'name' => 'string|min:1|max:255',
+            'book_id'     => 'integer',
+            'name'        => 'string|min:1|max:255',
             'description' => 'string|max:1000',
-            'tags' => 'array',
+            'tags'        => 'array',
         ],
     ];
 
@@ -40,9 +41,10 @@ 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',
+            'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
         ]);
     }
 
@@ -58,7 +60,6 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-create', $book);
 
         $chapter = $this->chapterRepo->create($request->all(), $book);
-        Activity::add($chapter, 'chapter_create', $book->id);
 
         return response()->json($chapter->load(['tags']));
     }
@@ -68,9 +69,10 @@ class ChapterApiController extends ApiController
      */
     public function read(string $id)
     {
-        $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'pages' => function (HasMany $query) {
+        $chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
             $query->visible()->get(['id', 'name', 'slug']);
         }])->findOrFail($id);
+
         return response()->json($chapter);
     }
 
@@ -83,13 +85,13 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-update', $chapter);
 
         $updatedChapter = $this->chapterRepo->update($chapter, $request->all());
-        Activity::add($chapter, 'chapter_update', $chapter->book->id);
 
         return response()->json($updatedChapter->load(['tags']));
     }
 
     /**
-     * Delete a chapter from the system.
+     * Delete a chapter.
+     * This will typically send the chapter to the recycle bin.
      */
     public function delete(string $id)
     {
@@ -97,7 +99,6 @@ class ChapterApiController extends ApiController
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
         $this->chapterRepo->destroy($chapter);
-        Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
 
         return response('', 204);
     }
index f19f29e9d2d752ee23182abbb0def5758e816714..5715ab2e37c6c9f7e682ad69b407f27153e3934e 100644 (file)
@@ -1,44 +1,47 @@
-<?php namespace BookStack\Http\Controllers\Api;
+<?php
 
-use BookStack\Entities\Chapter;
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Repos\BookRepo;
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Tools\ExportFormatter;
 use Throwable;
 
 class ChapterExportApiController extends ApiController
 {
-    protected $chapterRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * ChapterExportController constructor.
      */
-    public function __construct(BookRepo $chapterRepo, ExportService $exportService)
+    public function __construct(ExportFormatter $exportFormatter)
     {
-        $this->chapterRepo = $chapterRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $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->exportService->chapterToPdf($chapter);
+        $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->exportService->chapterToContainedHtml($chapter);
+        $htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
+
         return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
     }
 
@@ -48,7 +51,19 @@ class ChapterExportApiController extends ApiController
     public function exportPlainText(int $id)
     {
         $chapter = Chapter::visible()->findOrFail($id);
-        $textContent = $this->exportService->chapterToPlainText($chapter);
+        $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');
+    }
 }
diff --git a/app/Http/Controllers/Api/PageApiController.php b/app/Http/Controllers/Api/PageApiController.php
new file mode 100644 (file)
index 0000000..f698627
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exceptions\PermissionsException;
+use Exception;
+use Illuminate\Http\Request;
+
+class PageApiController extends ApiController
+{
+    protected $pageRepo;
+
+    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',
+        ],
+        'update' => [
+            'book_id'    => 'required|integer',
+            'chapter_id' => 'required|integer',
+            'name'       => 'string|min:1|max:255',
+            'html'       => 'string',
+            'markdown'   => 'string',
+            'tags'       => 'array',
+        ],
+    ];
+
+    public function __construct(PageRepo $pageRepo)
+    {
+        $this->pageRepo = $pageRepo;
+    }
+
+    /**
+     * Get a listing of pages visible to the user.
+     */
+    public function list()
+    {
+        $pages = Page::visible();
+
+        return $this->apiListingResponse($pages, [
+            'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
+            'draft', 'template',
+            'created_at', 'updated_at',
+            'created_by', 'updated_by', 'owned_by',
+        ]);
+    }
+
+    /**
+     * Create a new page in the system.
+     *
+     * The ID of a parent book or chapter is required to indicate
+     * where this page should be located.
+     *
+     * 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)
+    {
+        $this->validate($request, $this->rules['create']);
+
+        if ($request->has('chapter_id')) {
+            $parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
+        } else {
+            $parent = Book::visible()->findOrFail($request->get('book_id'));
+        }
+        $this->checkOwnablePermission('page-create', $parent);
+
+        $draft = $this->pageRepo->getNewDraftPage($parent);
+        $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
+
+        return response()->json($draft->forJsonDisplay());
+    }
+
+    /**
+     * View the details of a single page.
+     *
+     * Pages will always have HTML content. They may have markdown content
+     * if the markdown editor was used to last update the page.
+     */
+    public function read(string $id)
+    {
+        $page = $this->pageRepo->getById($id, []);
+
+        return response()->json($page->forJsonDisplay());
+    }
+
+    /**
+     * Update the details of a single page.
+     *
+     * See the 'create' action for details on the provided HTML/Markdown.
+     * Providing a 'book_id' or 'chapter_id' property will essentially move
+     * the page into that parent element if you have permissions to do so.
+     */
+    public function update(Request $request, string $id)
+    {
+        $page = $this->pageRepo->getById($id, []);
+        $this->checkOwnablePermission('page-update', $page);
+
+        $parent = null;
+        if ($request->has('chapter_id')) {
+            $parent = Chapter::visible()->findOrFail($request->get('chapter_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) {
+                if ($exception instanceof  PermissionsException) {
+                    $this->showPermissionError();
+                }
+
+                return $this->jsonError(trans('errors.selected_book_chapter_not_found'));
+            }
+        }
+
+        $updatedPage = $this->pageRepo->update($page, $request->all());
+
+        return response()->json($updatedPage->forJsonDisplay());
+    }
+
+    /**
+     * Delete a page.
+     * This will typically send the page to the recycle bin.
+     */
+    public function delete(string $id)
+    {
+        $page = $this->pageRepo->getById($id, []);
+        $this->checkOwnablePermission('page-delete', $page);
+
+        $this->pageRepo->destroy($page);
+
+        return response('', 204);
+    }
+}
diff --git a/app/Http/Controllers/Api/PageExportApiController.php b/app/Http/Controllers/Api/PageExportApiController.php
new file mode 100644 (file)
index 0000000..ce5700c
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\ExportFormatter;
+use Throwable;
+
+class PageExportApiController extends ApiController
+{
+    protected $exportFormatter;
+
+    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');
+    }
+
+    /**
+     * Export a page as a plain text file.
+     */
+    public function exportPlainText(int $id)
+    {
+        $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');
+    }
+}
index 8f5da49ed83c10c5979b9e4b8eaa74ac324e14b9..046b8c19dc83478c59c92b587f97e71ffc51d484 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;
@@ -8,28 +10,26 @@ use BookStack\Uploads\AttachmentService;
 use Exception;
 use Illuminate\Contracts\Filesystem\FileNotFoundException;
 use Illuminate\Http\Request;
+use Illuminate\Support\MessageBag;
 use Illuminate\Validation\ValidationException;
 
 class AttachmentController extends Controller
 {
     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;
-        parent::__construct();
     }
 
-
     /**
      * Endpoint at which attachments are uploaded to.
+     *
      * @throws ValidationException
      * @throws NotFoundException
      */
@@ -37,7 +37,7 @@ class AttachmentController extends Controller
     {
         $this->validate($request, [
             'uploaded_to' => 'required|integer|exists:pages,id',
-            'file' => 'required|file'
+            'file'        => 'required|file',
         ]);
 
         $pageId = $request->get('uploaded_to');
@@ -59,26 +59,19 @@ class AttachmentController extends Controller
 
     /**
      * Update an uploaded attachment.
+     *
      * @throws ValidationException
-     * @throws NotFoundException
      */
     public function uploadUpdate(Request $request, $attachmentId)
     {
         $this->validate($request, [
-            'uploaded_to' => 'required|integer|exists:pages,id',
-            'file' => 'required|file'
+            'file' => 'required|file',
         ]);
 
-        $pageId = $request->get('uploaded_to');
-        $page = $this->pageRepo->getById($pageId);
-        $attachment = $this->attachment->findOrFail($attachmentId);
-
-        $this->checkOwnablePermission('page-update', $page);
+        $attachment = Attachment::query()->findOrFail($attachmentId);
+        $this->checkOwnablePermission('view', $attachment->page);
+        $this->checkOwnablePermission('page-update', $attachment->page);
         $this->checkOwnablePermission('attachment-create', $attachment);
-        
-        if (intval($pageId) !== intval($attachment->uploaded_to)) {
-            return $this->jsonError(trans('errors.attachment_page_mismatch'));
-        }
 
         $uploadedFile = $request->file('file');
 
@@ -92,57 +85,90 @@ class AttachmentController extends Controller
     }
 
     /**
-     * Update the details of an existing file.
-     * @throws ValidationException
-     * @throws NotFoundException
+     * Get the update form for an attachment.
+     *
+     * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
      */
-    public function update(Request $request, $attachmentId)
+    public function getUpdateForm(string $attachmentId)
     {
-        $this->validate($request, [
-            'uploaded_to' => 'required|integer|exists:pages,id',
-            'name' => 'required|string|min:1|max:255',
-            'link' =>  'string|min:1|max:255'
+        $attachment = Attachment::query()->findOrFail($attachmentId);
+
+        $this->checkOwnablePermission('page-update', $attachment->page);
+        $this->checkOwnablePermission('attachment-create', $attachment);
+
+        return view('attachments.manager-edit-form', [
+            'attachment' => $attachment,
         ]);
+    }
 
-        $pageId = $request->get('uploaded_to');
-        $page = $this->pageRepo->getById($pageId);
-        $attachment = $this->attachment->findOrFail($attachmentId);
+    /**
+     * Update the details of an existing file.
+     */
+    public function update(Request $request, string $attachmentId)
+    {
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
 
-        $this->checkOwnablePermission('page-update', $page);
+        try {
+            $this->validate($request, [
+                '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()),
+            ]), 422);
+        }
+
+        $this->checkOwnablePermission('view', $attachment->page);
+        $this->checkOwnablePermission('page-update', $attachment->page);
         $this->checkOwnablePermission('attachment-create', $attachment);
 
-        if (intval($pageId) !== intval($attachment->uploaded_to)) {
-            return $this->jsonError(trans('errors.attachment_page_mismatch'));
-        }
+        $attachment = $this->attachmentService->updateFile($attachment, [
+            'name' => $request->get('attachment_edit_name'),
+            'link' => $request->get('attachment_edit_url'),
+        ]);
 
-        $attachment = $this->attachmentService->updateFile($attachment, $request->all());
-        return response()->json($attachment);
+        return view('attachments.manager-edit-form', [
+            'attachment' => $attachment,
+        ]);
     }
 
     /**
      * Attach a link to a page.
-     * @throws ValidationException
+     *
      * @throws NotFoundException
      */
     public function attachLink(Request $request)
     {
-        $this->validate($request, [
-            'uploaded_to' => 'required|integer|exists:pages,id',
-            'name' => 'required|string|min:1|max:255',
-            'link' =>  'required|string|min:1|max:255'
-        ]);
+        $pageId = $request->get('attachment_link_uploaded_to');
+
+        try {
+            $this->validate($request, [
+                'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
+                'attachment_link_name'        => 'required|string|min:1|max:255',
+                'attachment_link_url'         => 'required|string|min:1|max:255|safe_url',
+            ]);
+        } catch (ValidationException $exception) {
+            return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
+                'pageId' => $pageId,
+                'errors' => new MessageBag($exception->errors()),
+            ]), 422);
+        }
 
-        $pageId = $request->get('uploaded_to');
         $page = $this->pageRepo->getById($pageId);
 
         $this->checkPermission('attachment-create-all');
         $this->checkOwnablePermission('page-update', $page);
 
-        $attachmentName = $request->get('name');
-        $link = $request->get('link');
-        $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
+        $attachmentName = $request->get('attachment_link_name');
+        $link = $request->get('attachment_link_url');
+        $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
 
-        return response()->json($attachment);
+        return view('attachments.manager-link-form', [
+            'pageId' => $pageId,
+        ]);
     }
 
     /**
@@ -152,36 +178,43 @@ class AttachmentController extends Controller
     {
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-view', $page);
-        return response()->json($page->attachments);
+
+        return view('attachments.manager-list', [
+            'attachments' => $page->attachments->all(),
+        ]);
     }
 
     /**
      * Update the attachment sorting.
+     *
      * @throws ValidationException
      * @throws NotFoundException
      */
     public function sortForPage(Request $request, int $pageId)
     {
         $this->validate($request, [
-            'files' => 'required|array',
-            'files.*.id' => 'required|integer',
+            'order' => 'required|array',
         ]);
         $page = $this->pageRepo->getById($pageId);
         $this->checkOwnablePermission('page-update', $page);
 
-        $attachments = $request->get('files');
-        $this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
+        $attachmentOrder = $request->get('order');
+        $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
+
         return response()->json(['message' => trans('entities.attachments_order_updated')]);
     }
 
     /**
      * Get an attachment from storage.
+     *
      * @throws FileNotFoundException
      * @throws NotFoundException
      */
-    public function get(int $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) {
@@ -194,21 +227,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.
-     * @param $attachmentId
-     * @return mixed
+     *
      * @throws Exception
      */
-    public function delete(int $attachmentId)
+    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')]);
     }
 }
diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php
new file mode 100644 (file)
index 0000000..11efbfc
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\Activity;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+
+class AuditLogController extends Controller
+{
+    public function index(Request $request)
+    {
+        $this->checkPermission('settings-manage');
+        $this->checkPermission('users-manage');
+
+        $listDetails = [
+            'order'     => $request->get('order', 'desc'),
+            'event'     => $request->get('event', ''),
+            'sort'      => $request->get('sort', 'created_at'),
+            'date_from' => $request->get('date_from', ''),
+            'date_to'   => $request->get('date_to', ''),
+            'user'      => $request->get('user', ''),
+        ];
+
+        $query = Activity::query()
+            ->with([
+                'entity' => function ($query) {
+                    $query->withTrashed();
+                },
+                'user',
+            ])
+            ->orderBy($listDetails['sort'], $listDetails['order']);
+
+        if ($listDetails['event']) {
+            $query->where('type', '=', $listDetails['event']);
+        }
+        if ($listDetails['user']) {
+            $query->where('user_id', '=', $listDetails['user']);
+        }
+
+        if ($listDetails['date_from']) {
+            $query->where('created_at', '>=', $listDetails['date_from']);
+        }
+        if ($listDetails['date_to']) {
+            $query->where('created_at', '<=', $listDetails['date_to']);
+        }
+
+        $activities = $query->paginate(100);
+        $activities->appends($listDetails);
+
+        $types = DB::table('activities')->select('type')->distinct()->pluck('type');
+        $this->setPageTitle(trans('settings.audit'));
+
+        return view('settings.audit', [
+            'activities'    => $activities,
+            'listDetails'   => $listDetails,
+            'activityTypes' => $types,
+        ]);
+    }
+}
index 099558eb77fdce133b307aaac327277bc02510f8..02b9ef2760559e247f8f159a8f014732d571b76b 100644 (file)
@@ -3,6 +3,7 @@
 namespace BookStack\Http\Controllers\Auth;
 
 use BookStack\Auth\Access\EmailConfirmationService;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\ConfirmationEmailException;
 use BookStack\Exceptions\UserTokenExpiredException;
@@ -17,22 +18,22 @@ use Illuminate\View\View;
 class ConfirmEmailController extends Controller
 {
     protected $emailConfirmationService;
+    protected $loginService;
     protected $userRepo;
 
     /**
      * Create a new controller instance.
-     *
-     * @param EmailConfirmationService $emailConfirmationService
-     * @param UserRepo $userRepo
      */
-    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;
-        parent::__construct();
     }
 
-
     /**
      * Show the page to tell the user to check their email
      * and confirm their address.
@@ -45,19 +46,23 @@ 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
+     *
+     * @return RedirectResponse|Redirector
      */
     public function confirm($token)
     {
@@ -66,6 +71,7 @@ class ConfirmEmailController extends Controller
         } catch (Exception $exception) {
             if ($exception instanceof UserTokenNotFoundException) {
                 $this->showErrorNotification(trans('errors.email_confirmation_invalid'));
+
                 return redirect('/register');
             }
 
@@ -73,6 +79,7 @@ class ConfirmEmailController extends Controller
                 $user = $this->userRepo->getById($exception->userId);
                 $this->emailConfirmationService->sendConfirmation($user);
                 $this->showErrorNotification(trans('errors.email_confirmation_expired'));
+
                 return redirect('/register/confirm');
             }
 
@@ -83,23 +90,24 @@ class ConfirmEmailController extends Controller
         $user->email_confirmed = true;
         $user->save();
 
-        auth()->login($user);
-        $this->showSuccessNotification(trans('auth.email_confirm_success'));
         $this->emailConfirmationService->deleteByUser($user);
+        $this->showSuccessNotification(trans('auth.email_confirm_success'));
+        $this->loginService->login($user, auth()->getDefaultDriver());
 
         return redirect('/');
     }
 
-
     /**
-     * Resend the confirmation email
+     * Resend the confirmation email.
+     *
      * @param Request $request
+     *
      * @return View
      */
     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'));
 
@@ -107,10 +115,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 fadac641ecdb810b916560611029a1b517d3d6fe..3df0608f87ffad7f09754ce2f24e6d82694b716b 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
 use Illuminate\Http\Request;
@@ -31,14 +32,13 @@ class ForgotPasswordController extends Controller
     {
         $this->middleware('guest');
         $this->middleware('guard:standard');
-        parent::__construct();
     }
 
-
     /**
      * 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)
@@ -52,9 +52,14 @@ class ForgotPasswordController extends Controller
             $request->only('email')
         );
 
+        if ($response === Password::RESET_LINK_SENT) {
+            $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
+        }
+
         if ($response === Password::RESET_LINK_SENT || $response === Password::INVALID_USER) {
             $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 7ec7176468eea762fd5618e00e47d0fd7d7667b1..7c8eb2c864f2cdcb6a7030defaabec161540cd02 100644 (file)
@@ -2,13 +2,15 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use Activity;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Exceptions\LoginAttemptEmailNeededException;
 use BookStack\Exceptions\LoginAttemptException;
-use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\AuthenticatesUsers;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
 class LoginController extends Controller
 {
@@ -26,26 +28,28 @@ 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->socialAuthService = $socialAuthService;
+        $this->loginService = $loginService;
+
         $this->redirectPath = url('/');
         $this->redirectAfterLogout = url('/login');
-        parent::__construct();
     }
 
     public function username()
@@ -71,33 +75,33 @@ 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', '') : '',
             ]);
         }
 
-        $previous = url()->previous('');
-        if (setting('app-public') && $previous && $previous !== url('/login')) {
-            redirect()->setIntendedUrl($previous);
-        }
+        // Store the previous location for redirect after login
+        $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)
     {
         $this->validateLogin($request);
+        $username = $request->get($this->username());
 
         // If the class is using the ThrottlesLogins trait, we can automatically throttle
         // the login attempts for this application. We'll key this by the username and
@@ -106,6 +110,8 @@ class LoginController extends Controller
             $this->hasTooManyLoginAttempts($request)) {
             $this->fireLockoutEvent($request);
 
+            Activity::logFailedLogin($username);
+
             return $this->sendLockoutResponse($request);
         }
 
@@ -114,6 +120,8 @@ class LoginController extends Controller
                 return $this->sendLoginResponse($request);
             }
         } catch (LoginAttemptException $exception) {
+            Activity::logFailedLogin($username);
+
             return $this->sendLoginAttemptExceptionResponse($exception, $request);
         }
 
@@ -122,36 +130,48 @@ class LoginController extends Controller
         // user surpasses their maximum number of attempts they will get locked out.
         $this->incrementLoginAttempts($request);
 
+        Activity::logFailedLogin($username);
+
         return $this->sendFailedLoginResponse($request);
     }
 
+    /**
+     * 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', 'openid'];
-            foreach ($guards as $guard) {
-                auth($guard)->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)
     {
@@ -186,4 +206,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..d92029b
--- /dev/null
@@ -0,0 +1,98 @@
+<?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));
+
+        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..ca77cc7
--- /dev/null
@@ -0,0 +1,71 @@
+<?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');
+
+        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..694d69d
--- /dev/null
@@ -0,0 +1,97 @@
+<?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);
+        $svg = $totp->generateQrCodeSvg($qrCodeUrl);
+
+        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();
+    }
+}
index 0bdeef9e6855c1337c34ff934bece9ab5d42d45d..209827d6db800d7a75a7a568ec9a1af8468f4355 100644 (file)
@@ -2,15 +2,17 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+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\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\RegistersUsers;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Hash;
-use Validator;
+use Illuminate\Support\Facades\Validator;
 
 class RegisterController extends Controller
 {
@@ -29,6 +31,7 @@ class RegisterController extends Controller
 
     protected $socialAuthService;
     protected $registrationService;
+    protected $loginService;
 
     /**
      * Where to redirect users after login / registration.
@@ -41,17 +44,20 @@ 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('/');
-        parent::__construct();
     }
 
     /**
@@ -62,20 +68,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',
+            'name'     => 'required|min:2|max:255',
+            'email'    => 'required|email|max:255|unique:users',
             'password' => 'required|min:8',
         ]);
     }
 
     /**
      * Show the application registration form.
+     *
      * @throws UserRegistrationException
      */
     public function getRegister()
     {
         $this->registrationService->ensureRegistrationAllowed();
         $socialDrivers = $this->socialAuthService->getActiveDrivers();
+
         return view('auth.register', [
             'socialDrivers' => $socialDrivers,
         ]);
@@ -83,7 +91,9 @@ class RegisterController extends Controller
 
     /**
      * Handle a registration request for the application.
+     *
      * @throws UserRegistrationException
+     * @throws StoppedAuthenticationException
      */
     public function postRegister(Request $request)
     {
@@ -93,30 +103,33 @@ class RegisterController extends Controller
 
         try {
             $user = $this->registrationService->registerUser($userData);
-            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 efdf0015924f6d831a0233a737e7209ff246b7e0..a31529b119b502b2fbddc0d3779b147e628ba043 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Actions\ActivityType;
 use BookStack\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\ResetsPasswords;
 use Illuminate\Http\Request;
@@ -33,20 +34,22 @@ class ResetPasswordController extends Controller
     {
         $this->middleware('guest');
         $this->middleware('guard:standard');
-        parent::__construct();
     }
 
     /**
      * 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)
     {
         $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));
     }
@@ -54,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 7ffcc572bcd06dc43f003df6edb9f8c05d84720e..14eb65b717de6076ecbd76f1d352b877146c95f7 100644 (file)
@@ -7,7 +7,6 @@ use BookStack\Http\Controllers\Controller;
 
 class Saml2Controller extends Controller
 {
-
     protected $samlService;
 
     /**
@@ -15,7 +14,6 @@ class Saml2Controller extends Controller
      */
     public function __construct(Saml2Service $samlService)
     {
-        parent::__construct();
         $this->samlService = $samlService;
         $this->middleware('guard:saml2');
     }
@@ -51,8 +49,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',
         ]);
     }
 
@@ -64,6 +63,7 @@ class Saml2Controller extends Controller
     {
         $requestId = session()->pull('saml2_logout_request_id', null);
         $redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
+
         return redirect($redirect);
     }
 
@@ -78,10 +78,10 @@ class Saml2Controller extends Controller
         $user = $this->samlService->processAcsResponse($requestId);
         if ($user === null) {
             $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
+
             return redirect('/login');
         }
 
         return redirect()->intended();
     }
-
 }
index 0c53c92330b2504cece585772131c3147fa9e566..1691668a2bbc37163553167e3f20d0502c6af3cb 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\RegistrationService;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Exceptions\SocialDriverNotConfigured;
@@ -9,58 +10,64 @@ use BookStack\Exceptions\SocialSignInAccountNotUsed;
 use BookStack\Exceptions\SocialSignInException;
 use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Http\Controllers\Controller;
-use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
-use Illuminate\Routing\Redirector;
 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 \BookStack\Exceptions\SocialDriverNotConfigured
+     *
+     * @throws SocialDriverNotConfigured
      */
-    public function getSocialLogin(string $socialDriver)
+    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
      */
-    public function socialRegister(string $socialDriver)
+    public function register(string $socialDriver)
     {
         $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
      */
-    public function socialCallback(Request $request, string $socialDriver)
+    public function callback(Request $request, string $socialDriver)
     {
         if (!session()->has('social-callback')) {
             throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
@@ -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;
             }
         }
@@ -99,28 +107,30 @@ class SocialController extends Controller
     /**
      * Detach a social account from a user.
      */
-    public function detachSocialAccount(string $socialDriver)
+    public function detach(string $socialDriver)
     {
         $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)
     {
         $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
-        $socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
+        $socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
         $emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
 
         // 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,9 +139,9 @@ class SocialController extends Controller
         }
 
         $user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
-        auth()->login($user);
-
         $this->showSuccessNotification(trans('auth.register_success'));
+        $this->loginService->login($user, $socialDriver);
+
         return redirect('/');
     }
 }
index c61b1c42b688e58b8c6defd8c007f8db7a099009..bd1912b0b494313d410be80b0844ec15d6943ede 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace BookStack\Http\Controllers\Auth;
 
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\UserTokenExpiredException;
@@ -15,24 +16,25 @@ use Illuminate\Routing\Redirector;
 class UserInviteController extends Controller
 {
     protected $inviteService;
+    protected $loginService;
     protected $userRepo;
 
     /**
      * Create a new controller instance.
      */
-    public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
+    public function __construct(UserInviteService $inviteService, LoginService $loginService, UserRepo $userRepo)
     {
         $this->middleware('guest');
         $this->middleware('guard:standard');
 
         $this->inviteService = $inviteService;
+        $this->loginService = $loginService;
         $this->userRepo = $userRepo;
-
-        parent::__construct();
     }
 
     /**
      * Show the page for the user to set the password for their account.
+     *
      * @throws Exception
      */
     public function showSetPassword(string $token)
@@ -50,12 +52,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|min:8',
         ]);
 
         try {
@@ -69,17 +72,19 @@ class UserInviteController extends Controller
         $user->email_confirmed = true;
         $user->save();
 
-        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', ['appName' => setting('app-name')]));
+        $this->loginService->login($user, auth()->getDefaultDriver());
 
         return redirect('/');
     }
 
     /**
      * Check and validate the exception thrown when checking an invite token.
-     * @return RedirectResponse|Redirector
+     *
      * @throws Exception
+     *
+     * @return RedirectResponse|Redirector
      */
     protected function handleTokenException(Exception $exception)
     {
@@ -89,6 +94,7 @@ class UserInviteController extends Controller
 
         if ($exception instanceof UserTokenExpiredException) {
             $this->showErrorNotification(trans('errors.invite_token_expired'));
+
             return redirect('/password/email');
         }
 
index 1643c62f980cd151dabd3fedf0031084bd336c78..7c099377cb6eebddc1338d0e59616ddc468c2722 100644 (file)
@@ -1,31 +1,29 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\View;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\PermissionsUpdater;
+use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Exceptions\ImageUploadException;
-use BookStack\Exceptions\NotifyException;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
-use Views;
 
 class BookController extends Controller
 {
-
     protected $bookRepo;
     protected $entityContextManager;
 
-    /**
-     * BookController constructor.
-     */
-    public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
+    public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo)
     {
         $this->bookRepo = $bookRepo;
         $this->entityContextManager = $entityContextManager;
-        parent::__construct();
     }
 
     /**
@@ -33,7 +31,7 @@ class BookController extends Controller
      */
     public function index()
     {
-        $view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
+        $view = setting()->getForCurrentUser('books_view_type');
         $sort = setting()->getForCurrentUser('books_sort', 'name');
         $order = setting()->getForCurrentUser('books_sort_order', 'asc');
 
@@ -45,14 +43,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,
         ]);
     }
 
@@ -70,13 +69,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
      */
@@ -84,9 +85,9 @@ class BookController extends Controller
     {
         $this->checkPermission('book-create-all');
         $this->validate($request, [
-            'name' => 'required|string|max:255',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'image'       => 'nullable|' . $this->getImageValidationRules(),
         ]);
 
         $bookshelf = null;
@@ -97,11 +98,10 @@ class BookController extends Controller
 
         $book = $this->bookRepo->create($request->all());
         $this->bookRepo->updateCoverImage($book, $request->file('image', null));
-        Activity::add($book, 'book_create', $book->id);
 
         if ($bookshelf) {
             $bookshelf->appendBook($book);
-            Activity::add($bookshelf, 'bookshelf_update');
+            Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
         }
 
         return redirect($book->getUrl());
@@ -116,18 +116,19 @@ class BookController extends Controller
         $bookChildren = (new BookContents($book))->getTree(true);
         $bookParentShelves = $book->shelves()->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'          => Activity::entityActivity($book, 20, 1),
         ]);
     }
 
@@ -139,11 +140,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
@@ -153,17 +156,15 @@ class BookController extends Controller
         $book = $this->bookRepo->getBySlug($slug);
         $this->checkOwnablePermission('book-update', $book);
         $this->validate($request, [
-            'name' => 'required|string|max:255',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'image'       => 'nullable|' . $this->getImageValidationRules(),
         ]);
 
         $book = $this->bookRepo->update($book, $request->all());
         $resetCover = $request->has('image_reset');
         $this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
 
-        Activity::add($book, 'book_update', $book->id);
-
         return redirect($book->getUrl());
     }
 
@@ -175,20 +176,20 @@ 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
-     * @throws NotifyException
      */
     public function destroy(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('book-delete', $book);
 
-        Activity::addMessage('book_delete', $book->name);
         $this->bookRepo->destroy($book);
 
         return redirect('/books');
@@ -209,18 +210,18 @@ class BookController extends Controller
 
     /**
      * Set the restrictions for this book.
+     *
      * @throws Throwable
      */
-    public function permissions(Request $request, string $bookSlug)
+    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
         $this->checkOwnablePermission('restrictions-manage', $book);
 
-        $restricted = $request->get('restricted') === 'true';
-        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
-        $this->bookRepo->updatePermissions($book, $restricted, $permissions);
+        $permissionsUpdater->updateFromPermissionsForm($book, $request);
 
         $this->showSuccessNotification(trans('entities.books_permissions_updated'));
+
         return redirect($book->getUrl());
     }
 }
index cfa3d6a3a3d162e9eb5d3bf19afa5eb4a4f6b7b8..7f6dd801752b5e3901cb6188e3dc200888f32984 100644 (file)
@@ -2,45 +2,48 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\ExportService;
 use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\ExportFormatter;
 use Throwable;
 
 class BookExportController extends Controller
 {
-
     protected $bookRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * BookExportController constructor.
      */
-    public function __construct(BookRepo $bookRepo, ExportService $exportService)
+    public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
     {
         $this->bookRepo = $bookRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $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->exportService->bookToPdf($book);
+        $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->exportService->bookToContainedHtml($book);
+        $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
+
         return $this->downloadResponse($htmlContent, $bookSlug . '.html');
     }
 
@@ -50,7 +53,19 @@ class BookExportController extends Controller
     public function plainText(string $bookSlug)
     {
         $book = $this->bookRepo->getBySlug($bookSlug);
-        $textContent = $this->exportService->bookToPlainText($book);
+        $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 f5fb6f255537c2d16017f7365974863cc402260f..0bd39477801add649b70dce3e5f9b25f70fb8c65 100644 (file)
@@ -2,26 +2,21 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\BookContents;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Tools\BookContents;
 use BookStack\Exceptions\SortOperationException;
 use BookStack\Facades\Activity;
 use Illuminate\Http\Request;
 
 class BookSortController extends Controller
 {
-
     protected $bookRepo;
 
-    /**
-     * BookSortController constructor.
-     * @param $bookRepo
-     */
     public function __construct(BookRepo $bookRepo)
     {
         $this->bookRepo = $bookRepo;
-        parent::__construct();
     }
 
     /**
@@ -35,6 +30,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]);
     }
 
@@ -46,7 +42,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]);
     }
 
     /**
@@ -74,7 +71,7 @@ class BookSortController extends Controller
 
         // Rebuild permissions and add activity for involved books.
         $booksInvolved->each(function (Book $book) {
-            Activity::add($book, 'book_sort', $book->id);
+            Activity::addForEntity($book, ActivityType::BOOK_SORT);
         });
 
         return redirect($book->getUrl());
index f2cc11c7ba16126eb6f7fef610c8662525d190bf..da16d782281241112989661ee37c44d93a82bbf4 100644 (file)
@@ -1,33 +1,31 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\EntityContext;
+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\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;
 
-    /**
-     * BookController constructor.
-     */
-    public function __construct(BookshelfRepo $bookshelfRepo, EntityContext $entityContextManager, ImageRepo $imageRepo)
+    public function __construct(BookshelfRepo $bookshelfRepo, ShelfContext $entityContextManager, ImageRepo $imageRepo)
     {
         $this->bookshelfRepo = $bookshelfRepo;
         $this->entityContextManager = $entityContextManager;
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -35,11 +33,11 @@ class BookshelfController extends Controller
      */
     public function index()
     {
-        $view = setting()->getForCurrentUser('bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+        $view = setting()->getForCurrentUser('bookshelves_view_type');
         $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'),
         ];
@@ -51,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,
         ]);
     }
@@ -71,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
      */
@@ -83,21 +84,21 @@ class BookshelfController extends Controller
     {
         $this->checkPermission('bookshelf-create-all');
         $this->validate($request, [
-            'name' => 'required|string|max:255',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'image'       => 'nullable|' . $this->getImageValidationRules(),
         ]);
 
         $bookIds = explode(',', $request->get('books', ''));
         $shelf = $this->bookshelfRepo->create($request->all(), $bookIds);
         $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null));
 
-        Activity::add($shelf, 'bookshelf_create');
         return redirect($shelf->getUrl());
     }
 
     /**
      * Display the bookshelf of the given slug.
+     *
      * @throws NotFoundException
      */
     public function show(string $slug)
@@ -105,15 +106,27 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('book-view', $shelf);
 
-        Views::add($shelf);
+        $sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
+        $order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
+
+        $sortedVisibleShelfBooks = $shelf->visibleBooks()->get()
+            ->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $order === 'desc')
+            ->values()
+            ->all();
+
+        View::incrementFor($shelf);
         $this->entityContextManager->setShelfContext($shelf->id);
-        $view = setting()->getForCurrentUser('bookshelf_view_type', config('app.views.books'));
+        $view = setting()->getForCurrentUser('bookshelf_view_type');
 
         $this->setPageTitle($shelf->getShortName());
+
         return view('shelves.show', [
-            'shelf' => $shelf,
-            'view' => $view,
-            'activity' => Activity::entityActivity($shelf, 20, 1)
+            'shelf'                   => $shelf,
+            'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
+            'view'                    => $view,
+            'activity'                => Activity::entityActivity($shelf, 20, 1),
+            'order'                   => $order,
+            'sort'                    => $sort,
         ]);
     }
 
@@ -129,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,
@@ -137,6 +151,7 @@ class BookshelfController extends Controller
 
     /**
      * Update the specified bookshelf in storage.
+     *
      * @throws ValidationException
      * @throws ImageUploadException
      * @throws NotFoundException
@@ -146,23 +161,21 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-update', $shelf);
         $this->validate($request, [
-            'name' => 'required|string|max:255',
+            'name'        => 'required|string|max:255',
             'description' => 'string|max:1000',
-            'image' => 'nullable|' . $this->getImageValidationRules(),
+            'image'       => 'nullable|' . $this->getImageValidationRules(),
         ]);
 
-
         $bookIds = explode(',', $request->get('books', ''));
         $shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds);
         $resetCover = $request->has('image_reset');
         $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover);
-        Activity::add($shelf, 'bookshelf_update');
 
         return redirect($shelf->getUrl());
     }
 
     /**
-     * Shows the page to confirm deletion
+     * Shows the page to confirm deletion.
      */
     public function showDelete(string $slug)
     {
@@ -170,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)
@@ -182,7 +197,6 @@ class BookshelfController extends Controller
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-delete', $shelf);
 
-        Activity::addMessage('bookshelf_delete', $shelf->name);
         $this->bookshelfRepo->destroy($shelf);
 
         return redirect('/shelves');
@@ -204,16 +218,15 @@ class BookshelfController extends Controller
     /**
      * Set the permissions for this bookshelf.
      */
-    public function permissions(Request $request, string $slug)
+    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug)
     {
         $shelf = $this->bookshelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $restricted = $request->get('restricted') === 'true';
-        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
-        $this->bookshelfRepo->updatePermissions($shelf, $restricted, $permissions);
+        $permissionsUpdater->updateFromPermissionsForm($shelf, $request);
 
         $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
+
         return redirect($shelf->getUrl());
     }
 
@@ -227,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 1355979107eb0181d272e3610511688d5772b7b7..b27fb4f7747f99ff5e60f72723f3d6eebe47944b 100644 (file)
@@ -1,19 +1,21 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
-use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\BookContents;
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\View;
+use BookStack\Entities\Models\Book;
 use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\NextPreviousContentLocator;
+use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\NotFoundException;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
-use Views;
 
 class ChapterController extends Controller
 {
-
     protected $chapterRepo;
 
     /**
@@ -22,7 +24,6 @@ class ChapterController extends Controller
     public function __construct(ChapterRepo $chapterRepo)
     {
         $this->chapterRepo = $chapterRepo;
-        parent::__construct();
     }
 
     /**
@@ -34,24 +35,25 @@ 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();
         $this->checkOwnablePermission('chapter-create', $book);
 
         $chapter = $this->chapterRepo->create($request->all(), $book);
-        Activity::add($chapter, 'chapter_create', $book->id);
 
         return redirect($chapter->getUrl());
     }
@@ -66,15 +68,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(),
         ]);
     }
 
@@ -87,11 +93,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)
@@ -100,13 +108,13 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-update', $chapter);
 
         $this->chapterRepo->update($chapter, $request->all());
-        Activity::add($chapter, 'chapter_update', $chapter->book->id);
 
         return redirect($chapter->getUrl());
     }
 
     /**
      * Shows the page to confirm deletion of this chapter.
+     *
      * @throws NotFoundException
      */
     public function showDelete(string $bookSlug, string $chapterSlug)
@@ -115,11 +123,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
      */
@@ -128,7 +138,6 @@ class ChapterController extends Controller
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-delete', $chapter);
 
-        Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
         $this->chapterRepo->destroy($chapter);
 
         return redirect($chapter->book->getUrl());
@@ -136,6 +145,7 @@ class ChapterController extends Controller
 
     /**
      * Show the page for moving a chapter.
+     *
      * @throws NotFoundException
      */
     public function showMove(string $bookSlug, string $chapterSlug)
@@ -147,12 +157,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)
@@ -170,17 +181,18 @@ class ChapterController extends Controller
             $newBook = $this->chapterRepo->move($chapter, $entitySelection);
         } catch (MoveOperationException $exception) {
             $this->showErrorNotification(trans('errors.selected_book_not_found'));
+
             return redirect()->back();
         }
 
-        Activity::add($chapter, 'chapter_move', $newBook->id);
-
         $this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
+
         return redirect($chapter->getUrl());
     }
 
     /**
      * Show the Restrictions view.
+     *
      * @throws NotFoundException
      */
     public function showPermissions(string $bookSlug, string $chapterSlug)
@@ -195,18 +207,18 @@ class ChapterController extends Controller
 
     /**
      * Set the restrictions for this chapter.
+     *
      * @throws NotFoundException
      */
-    public function permissions(Request $request, string $bookSlug, string $chapterSlug)
+    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('restrictions-manage', $chapter);
 
-        $restricted = $request->get('restricted') === 'true';
-        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
-        $this->chapterRepo->updatePermissions($chapter, $restricted, $permissions);
+        $permissionsUpdater->updateFromPermissionsForm($chapter, $request);
 
         $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
+
         return redirect($chapter->getUrl());
     }
 }
index 0c86f854828b70dad5418a9b475c7262aef16612..480280c99ef6dc83a169ba1762d5e4fc0edd4d36 100644 (file)
@@ -1,58 +1,79 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\ExportService;
 use BookStack\Entities\Repos\ChapterRepo;
+use BookStack\Entities\Tools\ExportFormatter;
 use BookStack\Exceptions\NotFoundException;
 use Throwable;
 
 class ChapterExportController extends Controller
 {
-
     protected $chapterRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * ChapterExportController constructor.
      */
-    public function __construct(ChapterRepo $chapterRepo, ExportService $exportService)
+    public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
     {
         $this->chapterRepo = $chapterRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $this->exportFormatter = $exportFormatter;
+        $this->middleware('can:content-export');
     }
 
     /**
      * Exports a chapter to pdf.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
     public function pdf(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $pdfContent = $this->exportService->chapterToPdf($chapter);
+        $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
+
         return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
     }
 
     /**
      * Export a chapter to a self-contained HTML file.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
     public function html(string $bookSlug, string $chapterSlug)
     {
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
-        $containedHtml = $this->exportService->chapterToContainedHtml($chapter);
+        $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->exportService->chapterToPlainText($chapter);
+        $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 4eb56a4b0cd0720cfcc110e5c22662b958231f4b..dfe468f5f6f0992f9ebe73d0639567e8dec40141 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
-use Activity;
 use BookStack\Actions\CommentRepo;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 
@@ -13,17 +14,17 @@ class CommentController extends Controller
     public function __construct(CommentRepo $commentRepo)
     {
         $this->commentRepo = $commentRepo;
-        parent::__construct();
     }
 
     /**
-     * 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',
+            'text'      => 'required|string',
             'parent_id' => 'nullable|integer',
         ]);
 
@@ -40,12 +41,13 @@ 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'));
-        Activity::add($page, 'commented_on', $page->book->id);
+
         return view('comments.comment', ['comment' => $comment]);
     }
 
     /**
      * Update an existing comment.
+     *
      * @throws ValidationException
      */
     public function update(Request $request, int $commentId)
@@ -59,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]);
     }
 
@@ -71,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 2e8e8ed2ee12b005d58f9aab0492ef99dae0a56f..283a01cfb6a8852f23c0d73c9ef975189572492c 100644 (file)
@@ -2,24 +2,21 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Ownable;
+use BookStack\Facades\Activity;
+use BookStack\Interfaces\Loggable;
+use BookStack\Model;
+use finfo;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\Exceptions\HttpResponseException;
-use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Response;
 use Illuminate\Routing\Controller as BaseController;
 
 abstract class Controller extends BaseController
 {
-    use DispatchesJobs, ValidatesRequests;
-
-    /**
-     * Controller constructor.
-     */
-    public function __construct()
-    {
-        //
-    }
+    use DispatchesJobs;
+    use ValidatesRequests;
 
     /**
      * Check if the current user is signed in.
@@ -42,9 +39,8 @@ abstract class Controller extends BaseController
 
     /**
      * Adds the page title into the view.
-     * @param $title
      */
-    public function setPageTitle($title)
+    public function setPageTitle(string $title)
     {
         view()->share('pageTitle', $title);
     }
@@ -66,135 +62,120 @@ abstract class Controller extends BaseController
     }
 
     /**
-     * Checks for a permission.
-     * @param string $permissionName
-     * @return bool|\Illuminate\Http\RedirectResponse
+     * Checks that the current user has the given permission otherwise throw an exception.
      */
-    protected function checkPermission($permissionName)
+    protected function checkPermission(string $permission): void
     {
-        if (!user() || !user()->can($permissionName)) {
+        if (!user() || !user()->can($permission)) {
             $this->showPermissionError();
         }
-        return true;
     }
 
     /**
-     * Check the current user's permissions against an ownable item.
-     * @param $permission
-     * @param Ownable $ownable
-     * @return bool
+     * Check the current user's permissions against an ownable item otherwise throw an exception.
      */
-    protected function checkOwnablePermission($permission, Ownable $ownable)
+    protected function checkOwnablePermission(string $permission, Model $ownable): void
     {
-        if (userCan($permission, $ownable)) {
-            return true;
+        if (!userCan($permission, $ownable)) {
+            $this->showPermissionError();
         }
-        return $this->showPermissionError();
     }
 
     /**
-     * Check if a user has a permission or bypass if the callback is true.
-     * @param $permissionName
-     * @param $callback
-     * @return bool
+     * Check if a user has a permission or bypass the permission
+     * check if the given callback resolves true.
      */
-    protected function checkPermissionOr($permissionName, $callback)
+    protected function checkPermissionOr(string $permission, callable $callback): void
     {
-        $callbackResult = $callback();
-        if ($callbackResult === false) {
-            $this->checkPermission($permissionName);
+        if ($callback() !== true) {
+            $this->checkPermission($permission);
         }
-        return true;
     }
 
     /**
      * Check if the current user has a permission or bypass if the provided user
      * id matches the current user.
-     * @param string $permissionName
-     * @param int $userId
-     * @return bool
      */
-    protected function checkPermissionOrCurrentUser(string $permissionName, int $userId)
+    protected function checkPermissionOrCurrentUser(string $permission, int $userId): void
     {
-        return $this->checkPermissionOr($permissionName, function () use ($userId) {
+        $this->checkPermissionOr($permission, function () use ($userId) {
             return $userId === user()->id;
         });
     }
 
     /**
      * Send back a json error message.
-     * @param string $messageText
-     * @param int $statusCode
-     * @return mixed
      */
-    protected function jsonError($messageText = "", $statusCode = 500)
+    protected function jsonError(string $messageText = '', int $statusCode = 500): JsonResponse
     {
         return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
     }
 
     /**
-     * Create the response for when a request fails validation.
-     * @param  \Illuminate\Http\Request  $request
-     * @param  array  $errors
-     * @return \Symfony\Component\HttpFoundation\Response
+     * Create a response that forces a download in the browser.
      */
-    protected function buildFailedValidationResponse(Request $request, array $errors)
+    protected function downloadResponse(string $content, string $fileName): Response
     {
-        if ($request->expectsJson()) {
-            return response()->json(['validation' => $errors], 422);
-        }
-
-        return redirect()->to($this->getRedirectUrl())
-            ->withInput($request->input())
-            ->withErrors($errors, $this->errorBag());
+        return response()->make($content, 200, [
+            'Content-Type'        => 'application/octet-stream',
+            'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
+        ]);
     }
 
     /**
-     * Create a response that forces a download in the browser.
-     * @param string $content
-     * @param string $fileName
-     * @return \Illuminate\Http\Response
+     * 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 downloadResponse(string $content, string $fileName)
+    protected function inlineDownloadResponse(string $content, string $fileName): Response
     {
+        $finfo = new finfo(FILEINFO_MIME_TYPE);
+        $mime = $finfo->buffer($content) ?: 'application/octet-stream';
+
         return response()->make($content, 200, [
-            'Content-Type'        => 'application/octet-stream',
-            'Content-Disposition' => 'attachment; filename="' . $fileName . '"'
+            'Content-Type'        => $mime,
+            'Content-Disposition' => 'inline; filename="' . $fileName . '"',
         ]);
     }
 
     /**
      * Show a positive, successful notification to the user on next view load.
-     * @param string $message
      */
-    protected function showSuccessNotification(string $message)
+    protected function showSuccessNotification(string $message): void
     {
         session()->flash('success', $message);
     }
 
     /**
      * Show a warning notification to the user on next view load.
-     * @param string $message
      */
-    protected function showWarningNotification(string $message)
+    protected function showWarningNotification(string $message): void
     {
         session()->flash('warning', $message);
     }
 
     /**
      * Show an error notification to the user on next view load.
-     * @param string $message
      */
-    protected function showErrorNotification(string $message)
+    protected function showErrorNotification(string $message): void
     {
         session()->flash('error', $message);
     }
 
+    /**
+     * Log an activity in the system.
+     *
+     * @param string|Loggable
+     */
+    protected function logActivity(string $type, $detail = ''): void
+    {
+        Activity::add($type, $detail);
+    }
+
     /**
      * Get the validation rules for image files.
      */
     protected function getImageValidationRules(): string
     {
-        return 'image_extension|no_double_extension|mimes:jpeg,png,gif,webp';
+        return 'image_extension|mimes:jpeg,png,gif,webp';
     }
 }
diff --git a/app/Http/Controllers/FavouriteController.php b/app/Http/Controllers/FavouriteController.php
new file mode 100644 (file)
index 0000000..a990ff8
--- /dev/null
@@ -0,0 +1,97 @@
+<?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;
+
+        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): Favouritable
+    {
+        $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 60d2664d03a81107b9427f1258a8a82664551c90..5451c0abfe8289730e26649eec36c713aa3c5d36 100644 (file)
@@ -1,20 +1,20 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+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.
-     * @return Response
      */
     public function index()
     {
@@ -22,17 +22,26 @@ class HomeController extends Controller
         $draftPages = [];
 
         if ($this->isSignedIn()) {
-            $draftPages = Page::visible()->where('draft', '=', true)
+            $draftPages = Page::visible()
+                ->where('draft', '=', true)
                 ->where('created_by', '=', user()->id)
-                ->orderBy('updated_at', 'desc')->take(6)->get();
+                ->orderBy('updated_at', 'desc')
+                ->with('book')
+                ->take(6)
+                ->get();
         }
 
         $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
         $recents = $this->isSignedIn() ?
-              Views::getUserRecentlyViewed(12*$recentFactor, 0)
+            (new RecentlyViewed())->run(12 * $recentFactor, 1)
             : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
-        $recentlyUpdatedPages = Page::visible()->where('draft', false)
-            ->orderBy('updated_at', 'desc')->take(12)->get();
+        $favourites = (new TopFavourites())->run(6);
+        $recentlyUpdatedPages = Page::visible()->with('book')
+            ->where('draft', false)
+            ->orderBy('updated_at', 'desc')
+            ->take($favourites->count() > 0 ? 6 : 12)
+            ->select(Page::$listAttributes)
+            ->get();
 
         $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
         $homepageOption = setting('app-homepage-type', 'default');
@@ -41,29 +50,30 @@ 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.
         if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
             $key = $homepageOption;
-            $view = setting()->getForCurrentUser($key . '_view_type', config('app.views.' . $key));
+            $view = setting()->getForCurrentUser($key . '_view_type');
             $sort = setting()->getForCurrentUser($key . '_sort', 'name');
             $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,
             ]);
         }
@@ -71,57 +81,61 @@ 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-content');
+        return view('common.custom-head');
     }
 
     /**
-     * Show the view for /robots.txt
-     * @return $this
+     * Show the view for /robots.txt.
      */
-    public function getRobots()
+    public function robots()
     {
         $sitePublic = setting('app-public', false);
         $allowRobots = config('app.allow_robots');
+
         if ($allowRobots === null) {
             $allowRobots = $sitePublic;
         }
+
         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 106dfd63089a6111f042bfc76a32fd4c6ebdde2f..d99bb8e6f6acbb080712e8089bdfe7cfb4e2c36c 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
 {
@@ -15,7 +15,6 @@ class DrawioImageController extends Controller
     public function __construct(ImageRepo $imageRepo)
     {
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -30,18 +29,23 @@ class DrawioImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-        return response()->json($imgData);
+
+        return view('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,15 +69,16 @@ class DrawioImageController extends Controller
         $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");
+            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");
+            return $this->jsonError('Image data could not be found');
         }
+
         return response()->json([
-            'content' => base64_encode($imageData)
+            'content' => base64_encode($imageData),
         ]);
     }
 }
index e506215ca43b919a9d79d4b6ba743f33c39e13ca..5484411d36c4208da2055e37d9e5335689c8270a 100644 (file)
@@ -3,9 +3,10 @@
 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
 {
@@ -13,19 +14,15 @@ class GalleryImageController extends Controller
 
     /**
      * GalleryImageController constructor.
-     * @param ImageRepo $imageRepo
      */
     public function __construct(ImageRepo $imageRepo)
     {
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
      * Get a list of gallery images, in a list.
      * Can be paged and filtered by entity.
-     * @param Request $request
-     * @return \Illuminate\Http\JsonResponse
      */
     public function list(Request $request)
     {
@@ -35,20 +32,23 @@ class GalleryImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-        return response()->json($imgData);
+
+        return view('pages.parts.image-manager-list', [
+            'images'  => $imgData['images'],
+            'hasMore' => $imgData['has_more'],
+        ]);
     }
 
     /**
      * Store a new gallery image in the system.
-     * @param Request $request
-     * @return Illuminate\Http\JsonResponse
-     * @throws \Exception
+     *
+     * @throws ValidationException
      */
     public function create(Request $request)
     {
         $this->checkPermission('image-create-all');
         $this->validate($request, [
-            'file' => $this->getImageValidationRules()
+            'file' => $this->getImageValidationRules(),
         ]);
 
         try {
index 9c67704dd339bd0b21d3471b751206625386869b..4070a0e2fe63d1ec13061aba500b2baaf0c8ea87 100644 (file)
@@ -1,13 +1,16 @@
-<?php namespace BookStack\Http\Controllers\Images;
+<?php
+
+namespace BookStack\Http\Controllers\Images;
 
-use BookStack\Entities\Page;
 use BookStack\Exceptions\ImageUploadException;
+use BookStack\Exceptions\NotFoundException;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Repos\PageRepo;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use Exception;
 use Illuminate\Filesystem\Filesystem as File;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
 class ImageController extends Controller
 {
@@ -17,46 +20,41 @@ class ImageController extends Controller
 
     /**
      * ImageController constructor.
-     * @param Image $image
-     * @param File $file
-     * @param ImageRepo $imageRepo
      */
     public function __construct(Image $image, File $file, ImageRepo $imageRepo)
     {
         $this->image = $image;
         $this->file = $file;
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
      * Provide an image file from storage.
-     * @param string $path
-     * @return mixed
+     *
+     * @throws NotFoundException
      */
     public function showImage(string $path)
     {
         $path = storage_path('uploads/images/' . $path);
         if (!file_exists($path)) {
-            abort(404);
+            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);
     }
 
-
     /**
-     * Update image details
-     * @param Request $request
-     * @param integer $id
-     * @return \Illuminate\Http\JsonResponse
+     * Update image details.
+     *
      * @throws ImageUploadException
-     * @throws \Exception
+     * @throws ValidationException
      */
-    public function update(Request $request, $id)
+    public function update(Request $request, string $id)
     {
         $this->validate($request, [
-            'name' => 'required|min:2|string'
+            'name' => 'required|min:2|string',
         ]);
 
         $image = $this->imageRepo->getById($id);
@@ -64,47 +62,55 @@ class ImageController extends Controller
         $this->checkOwnablePermission('image-update', $image);
 
         $image = $this->imageRepo->updateImageDetails($image, $request->all());
-        return response()->json($image);
+
+        $this->imageRepo->loadThumbs($image);
+
+        return view('pages.parts.image-manager-form', [
+            'image'          => $image,
+            'dependantPages' => null,
+        ]);
     }
 
     /**
-     * Show the usage of an image on pages.
+     * Get the form for editing the given image.
+     *
+     * @throws Exception
      */
-    public function usage(int $id)
+    public function edit(Request $request, string $id)
     {
         $image = $this->imageRepo->getById($id);
         $this->checkImagePermission($image);
 
-        $pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
-        foreach ($pages as $page) {
-            $page->url = $page->getUrl();
-            $page->html = '';
-            $page->text = '';
+        if ($request->has('delete')) {
+            $dependantPages = $this->imageRepo->getPagesUsingImage($image);
         }
-        $result = count($pages) > 0 ? $pages : false;
 
-        return response()->json($result);
+        $this->imageRepo->loadThumbs($image);
+
+        return view('pages.parts.image-manager-form', [
+            'image'          => $image,
+            'dependantPages' => $dependantPages ?? null,
+        ]);
     }
 
     /**
-     * Deletes an image and all thumbnail/image files
-     * @param int $id
-     * @return \Illuminate\Http\JsonResponse
-     * @throws \Exception
+     * Deletes an image and all thumbnail/image files.
+     *
+     * @throws Exception
      */
-    public function destroy($id)
+    public function destroy(string $id)
     {
         $image = $this->imageRepo->getById($id);
         $this->checkOwnablePermission('image-delete', $image);
         $this->checkImagePermission($image);
 
         $this->imageRepo->destroyImage($image);
-        return response()->json(trans('components.images_deleted'));
+
+        return response('');
     }
 
     /**
      * Check related page permission and ensure type is drawio or gallery.
-     * @param Image $image
      */
     protected function checkImagePermission(Image $image)
     {
diff --git a/app/Http/Controllers/MaintenanceController.php b/app/Http/Controllers/MaintenanceController.php
new file mode 100644 (file)
index 0000000..d6abe46
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Notifications\TestEmail;
+use BookStack\Uploads\ImageService;
+use Illuminate\Http\Request;
+
+class MaintenanceController extends Controller
+{
+    /**
+     * Show the page for application maintenance.
+     */
+    public function index()
+    {
+        $this->checkPermission('settings-manage');
+        $this->setPageTitle(trans('settings.maint'));
+
+        // Get application version
+        $version = trim(file_get_contents(base_path('version')));
+
+        // Recycle bin details
+        $recycleStats = (new TrashCan())->getTrashedCounts();
+
+        return view('settings.maintenance', [
+            'version'      => $version,
+            'recycleStats' => $recycleStats,
+        ]);
+    }
+
+    /**
+     * Action to clean-up images in the system.
+     */
+    public function cleanupImages(Request $request, ImageService $imageService)
+    {
+        $this->checkPermission('settings-manage');
+        $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images');
+
+        $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
+        $dryRun = !($request->has('confirm'));
+
+        $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
+        $deleteCount = count($imagesToDelete);
+        if ($deleteCount === 0) {
+            $this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
+
+            return redirect('/settings/maintenance')->withInput();
+        }
+
+        if ($dryRun) {
+            session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
+        } else {
+            $this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
+        }
+
+        return redirect('/settings/maintenance#image-cleanup')->withInput();
+    }
+
+    /**
+     * Action to send a test e-mail to the current user.
+     */
+    public function sendTestEmail()
+    {
+        $this->checkPermission('settings-manage');
+        $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
+
+        try {
+            user()->notify(new TestEmail());
+            $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
+        } catch (\Exception $exception) {
+            $errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
+            $this->showErrorNotification($errorMessage);
+        }
+
+        return redirect('/settings/maintenance#image-cleanup')->withInput();
+    }
+}
index b216c19a8e7689a2e4797f12c9da8e2c92512c0b..9025db162ab949709287a298c6918464c177cfb1 100644 (file)
@@ -1,23 +1,25 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
-use Activity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Managers\PageEditActivity;
-use BookStack\Entities\Page;
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\View;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\NextPreviousContentLocator;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Tools\PageEditActivity;
+use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
-use Views;
 
 class PageController extends Controller
 {
-
     protected $pageRepo;
 
     /**
@@ -26,11 +28,11 @@ class PageController extends Controller
     public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
     /**
      * Show the form for creating a new page.
+     *
      * @throws Throwable
      */
     public function create(string $bookSlug, string $chapterSlug = null)
@@ -41,22 +43,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);
@@ -65,7 +70,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'));
@@ -73,41 +78,42 @@ class PageController extends Controller
 
     /**
      * Show form to continue editing a draft page.
+     *
      * @throws NotFoundException
      */
     public function editDraft(string $bookSlug, int $pageId)
     {
         $draft = $this->pageRepo->getById($pageId);
-        $this->checkOwnablePermission('page-create', $draft->parent());
+        $this->checkOwnablePermission('page-create', $draft->getParent());
         $this->setPageTitle(trans('entities.pages_edit_draft'));
 
         $draftsEnabled = $this->isSignedIn();
         $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->parent());
+        $this->checkOwnablePermission('page-create', $draftPage->getParent());
 
         $page = $this->pageRepo->publishDraft($draftPage, $request->all());
-        Activity::add($page, 'page_create', $draftPage->book->id);
 
         return redirect($page->getUrl());
     }
@@ -115,6 +121,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)
@@ -144,30 +151,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']);
+
         return response()->json($page);
     }
 
     /**
      * Show the form for editing the specified page.
+     *
      * @throws NotFoundException
      */
     public function edit(string $bookSlug, string $pageSlug)
@@ -199,36 +216,38 @@ 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);
 
         $this->pageRepo->update($page, $request->all());
-        Activity::add($page, 'page_update', $page->book->id);
 
         return redirect($page->getUrl());
     }
 
     /**
      * Save a draft update as a revision.
+     *
      * @throws NotFoundException
      */
     public function saveDraft(Request $request, int $pageId)
@@ -241,81 +260,85 @@ 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)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('page-delete', $page);
+        $parent = $page->getParent();
 
-        $book = $page->book;
-        $parent = $page->chapter ?? $book;
         $this->pageRepo->destroy($page);
-        Activity::addMessage('page_delete', $page->name, $book->id);
 
-        $this->showSuccessNotification(trans('entities.pages_delete_success'));
         return redirect($parent->getUrl());
     }
 
     /**
      * Remove the specified draft page from storage.
+     *
      * @throws NotFoundException
-     * @throws NotifyException
      * @throws Throwable
      */
     public function destroyDraft(string $bookSlug, int $pageId)
@@ -332,6 +355,7 @@ class PageController extends Controller
         if ($chapter && userCan('view', $chapter)) {
             return redirect($chapter->getUrl());
         }
+
         return redirect($book->getUrl());
     }
 
@@ -344,14 +368,15 @@ class PageController extends Controller
             ->paginate(20)
             ->setPath(url('/pages/recently-updated'));
 
-        return view('pages.detailed-listing', [
-            'title' => trans('entities.recently_updated_pages'),
-            'pages' => $pages
+        return view('common.detailed-listing-paginated', [
+            'title'    => trans('entities.recently_updated_pages'),
+            'entities' => $pages,
         ]);
     }
 
     /**
      * Show the view to choose a new parent to move a page into.
+     *
      * @throws NotFoundException
      */
     public function showMove(string $bookSlug, string $pageSlug)
@@ -359,14 +384,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
      */
@@ -384,21 +411,23 @@ class PageController extends Controller
         try {
             $parent = $this->pageRepo->move($page, $entitySelection);
         } catch (Exception $exception) {
-            if ($exception instanceof  PermissionsException) {
+            if ($exception instanceof PermissionsException) {
                 $this->showPermissionError();
             }
 
             $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
+
             return redirect()->back();
         }
 
-        Activity::add($page, 'page_move', $page->book->id);
         $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)
@@ -406,15 +435,16 @@ 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
      */
@@ -429,48 +459,50 @@ class PageController extends Controller
         try {
             $pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
         } catch (Exception $exception) {
-            if ($exception instanceof  PermissionsException) {
+            if ($exception instanceof PermissionsException) {
                 $this->showPermissionError();
             }
 
             $this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
+
             return redirect()->back();
         }
 
-        Activity::add($pageCopy, 'page_create', $pageCopy->book->id);
-
         $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
      */
-    public function permissions(Request $request, string $bookSlug, string $pageSlug)
+    public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $this->checkOwnablePermission('restrictions-manage', $page);
 
-        $restricted = $request->get('restricted') === 'true';
-        $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
-        $this->pageRepo->updatePermissions($page, $restricted, $permissions);
+        $permissionsUpdater->updateFromPermissionsForm($page, $request);
 
         $this->showSuccessNotification(trans('entities.pages_permissions_success'));
+
         return redirect($page->getUrl());
     }
 }
index 3b02ea224716c4f01bc1ceffdd043d5e88702ba4..0287916de28f40008eeb2d16e948d6274eb77a3a 100644 (file)
@@ -2,33 +2,31 @@
 
 namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Managers\PageContent;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Exceptions\NotFoundException;
 use Throwable;
 
 class PageExportController extends Controller
 {
-
     protected $pageRepo;
-    protected $exportService;
+    protected $exportFormatter;
 
     /**
      * PageExportController constructor.
-     * @param PageRepo $pageRepo
-     * @param ExportService $exportService
      */
-    public function __construct(PageRepo $pageRepo, ExportService $exportService)
+    public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
     {
         $this->pageRepo = $pageRepo;
-        $this->exportService = $exportService;
-        parent::__construct();
+        $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
      */
@@ -36,12 +34,14 @@ class PageExportController extends Controller
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $page->html = (new PageContent($page))->render();
-        $pdfContent = $this->exportService->pageToPdf($page);
+        $pdfContent = $this->exportFormatter->pageToPdf($page);
+
         return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
     }
 
     /**
      * Export a page to a self-contained HTML file.
+     *
      * @throws NotFoundException
      * @throws Throwable
      */
@@ -49,18 +49,34 @@ class PageExportController extends Controller
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
         $page->html = (new PageContent($page))->render();
-        $containedHtml = $this->exportService->pageToContainedHtml($page);
+        $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->exportService->pageToPlainText($page);
+        $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 797f5db8f43ff1ca9f8f561e029b888cec7a662d..d595a6e26fd797cb0c7693f473e0680a168caf3e 100644 (file)
@@ -1,14 +1,14 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
-use BookStack\Entities\Managers\PageContent;
 use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\PageContent;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Facades\Activity;
-use GatherContent\Htmldiff\Htmldiff;
+use Ssddanbrown\HtmlDiff\Diff;
 
 class PageRevisionController extends Controller
 {
-
     protected $pageRepo;
 
     /**
@@ -17,25 +17,27 @@ class PageRevisionController extends Controller
     public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
     /**
      * 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)
@@ -52,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)
@@ -74,7 +78,7 @@ class PageRevisionController extends Controller
 
         $prev = $revision->getPrevious();
         $prevContent = $prev->html ?? '';
-        $diff = (new Htmldiff)->diff($prevContent, $revision->html);
+        $diff = Diff::excecute($prevContent, $revision->html);
 
         $page->fill($revision->toArray());
         // TODO - Refactor PageContent so we don't need to juggle this
@@ -83,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)
@@ -101,12 +106,12 @@ class PageRevisionController extends Controller
 
         $page = $this->pageRepo->restoreRevision($page, $revisionId);
 
-        Activity::add($page, 'page_restore', $page->book->id);
         return redirect($page->getUrl());
     }
 
     /**
      * Deletes a revision using the id of the specified revision.
+     *
      * @throws NotFoundException
      */
     public function destroy(string $bookSlug, string $pageSlug, int $revId)
@@ -125,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 eaa1a8ae26ae18f28473c6796062dfb5065dba97..1e24c29eeffd40c27fa63ffbe6168fb27f7ade49 100644 (file)
@@ -11,12 +11,11 @@ class PageTemplateController extends Controller
     protected $pageRepo;
 
     /**
-     * PageTemplateController constructor
+     * PageTemplateController constructor.
      */
     public function __construct(PageRepo $pageRepo)
     {
         $this->pageRepo = $pageRepo;
-        parent::__construct();
     }
 
     /**
@@ -32,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)
@@ -50,7 +50,7 @@ class PageTemplateController extends Controller
         }
 
         return response()->json([
-            'html' => $page->html,
+            'html'     => $page->html,
             'markdown' => $page->markdown,
         ]);
     }
diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php
new file mode 100644 (file)
index 0000000..1736023
--- /dev/null
@@ -0,0 +1,131 @@
+<?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';
+
+    /**
+     * On each request to a method of this controller check permissions
+     * using a middleware closure.
+     */
+    public function __construct()
+    {
+        $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.
+     */
+    public function index()
+    {
+        $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
+
+        $this->setPageTitle(trans('settings.recycle_bin'));
+
+        return view('settings.recycle-bin.index', [
+            'deletions' => $deletions,
+        ]);
+    }
+
+    /**
+     * Show the page to confirm a restore of the deletion of the given id.
+     */
+    public function showRestore(string $id)
+    {
+        /** @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,
+            'parentDeletion' => $parentDeletion,
+        ]);
+    }
+
+    /**
+     * Restore the element attached to the given deletion.
+     *
+     * @throws \Exception
+     */
+    public function restore(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        $this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
+        $restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
+
+        $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
+
+        return redirect($this->recycleBinBaseUrl);
+    }
+
+    /**
+     * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id.
+     */
+    public function showDestroy(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+
+        return view('settings.recycle-bin.destroy', [
+            'deletion' => $deletion,
+        ]);
+    }
+
+    /**
+     * Permanently delete the content associated with the given deletion.
+     *
+     * @throws \Exception
+     */
+    public function destroy(string $id)
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        $this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
+        $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()
+    {
+        $deleteCount = (new TrashCan())->empty();
+
+        $this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
+        $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+
+        return redirect($this->recycleBinBaseUrl);
+    }
+}
similarity index 66%
rename from app/Http/Controllers/PermissionController.php
rename to app/Http/Controllers/RoleController.php
index 148ae5cd65a3049e421ea2b331f3bfa34f31ed4d..06a30e99dd5c5ff2a43c0cbb3b68d68bd0312b50 100644 (file)
-<?php namespace BookStack\Http\Controllers;
+<?php
+
+namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\Permissions\PermissionsRepo;
 use BookStack\Exceptions\PermissionsException;
+use Exception;
 use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
 
-class PermissionController extends Controller
+class RoleController extends Controller
 {
-
     protected $permissionsRepo;
 
     /**
      * PermissionController constructor.
-     * @param \BookStack\Auth\Permissions\PermissionsRepo $permissionsRepo
      */
     public function __construct(PermissionsRepo $permissionsRepo)
     {
         $this->permissionsRepo = $permissionsRepo;
-        parent::__construct();
     }
 
     /**
      * Show a listing of the roles in the system.
      */
-    public function listRoles()
+    public function list()
     {
         $this->checkPermission('user-roles-manage');
         $roles = $this->permissionsRepo->getAllRoles();
+
         return view('settings.roles.index', ['roles' => $roles]);
     }
 
     /**
-     * Show the form to create a new role
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+     * Show the form to create a new role.
      */
-    public function createRole()
+    public function create()
     {
         $this->checkPermission('user-roles-manage');
+
         return view('settings.roles.create');
     }
 
     /**
      * Store a new role in the system.
-     * @param Request $request
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
      */
-    public function storeRole(Request $request)
+    public function store(Request $request)
     {
         $this->checkPermission('user-roles-manage');
         $this->validate($request, [
-            'display_name' => 'required|min:3|max:200',
-            'description' => 'max:250'
+            'display_name' => 'required|min:3|max:180',
+            'description'  => 'max:180',
         ]);
 
         $this->permissionsRepo->saveNewRole($request->all());
         $this->showSuccessNotification(trans('settings.role_create_success'));
+
         return redirect('/settings/roles');
     }
 
     /**
      * Show the form for editing a user role.
-     * @param $id
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+     *
      * @throws PermissionsException
      */
-    public function editRole($id)
+    public function edit(string $id)
     {
         $this->checkPermission('user-roles-manage');
         $role = $this->permissionsRepo->getRoleById($id);
         if ($role->hidden) {
             throw new PermissionsException(trans('errors.role_cannot_be_edited'));
         }
+
         return view('settings.roles.edit', ['role' => $role]);
     }
 
     /**
      * Updates a user role.
-     * @param Request $request
-     * @param $id
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-     * @throws PermissionsException
-     * @throws \Illuminate\Validation\ValidationException
+     *
+     * @throws ValidationException
      */
-    public function updateRole(Request $request, $id)
+    public function update(Request $request, string $id)
     {
         $this->checkPermission('user-roles-manage');
         $this->validate($request, [
-            'display_name' => 'required|min:3|max:200',
-            'description' => 'max:250'
+            'display_name' => 'required|min:3|max:180',
+            'description'  => 'max:180',
         ]);
 
         $this->permissionsRepo->updateRole($id, $request->all());
         $this->showSuccessNotification(trans('settings.role_update_success'));
+
         return redirect('/settings/roles');
     }
 
     /**
      * Show the view to delete a role.
      * Offers the chance to migrate users.
-     * @param $id
-     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
      */
-    public function showDeleteRole($id)
+    public function showDelete(string $id)
     {
         $this->checkPermission('user-roles-manage');
         $role = $this->permissionsRepo->getRoleById($id);
         $roles = $this->permissionsRepo->getAllRolesExcept($role);
         $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]);
         $roles->prepend($blankRole);
+
         return view('settings.roles.delete', ['role' => $role, 'roles' => $roles]);
     }
 
     /**
      * Delete a role from the system,
      * Migrate from a previous role if set.
-     * @param Request $request
-     * @param $id
-     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+     *
+     * @throws Exception
      */
-    public function deleteRole(Request $request, $id)
+    public function delete(Request $request, string $id)
     {
         $this->checkPermission('user-roles-manage');
 
@@ -125,10 +122,12 @@ class PermissionController 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 8105843b576acb9072651c878190a5489968f7b4..d12c23b5a2c4404cb665bec99c5160eeff4e700a 100644 (file)
@@ -1,32 +1,25 @@
-<?php namespace BookStack\Http\Controllers;
-
-use BookStack\Actions\ViewService;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Managers\EntityContext;
-use BookStack\Entities\SearchService;
-use BookStack\Entities\SearchOptions;
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Queries\Popular;
+use BookStack\Entities\Tools\SearchOptions;
+use BookStack\Entities\Tools\SearchRunner;
+use BookStack\Entities\Tools\ShelfContext;
+use BookStack\Entities\Tools\SiblingFetcher;
 use Illuminate\Http\Request;
 
 class SearchController extends Controller
 {
-    protected $viewService;
-    protected $searchService;
+    protected $searchRunner;
     protected $entityContextManager;
 
-    /**
-     * SearchController constructor.
-     */
     public function __construct(
-        ViewService $viewService,
-        SearchService $searchService,
-        EntityContext $entityContextManager
+        SearchRunner $searchRunner,
+        ShelfContext $entityContextManager
     ) {
-        $this->viewService = $viewService;
-        $this->searchService = $searchService;
+        $this->searchRunner = $searchRunner;
         $this->entityContextManager = $entityContextManager;
-        parent::__construct();
     }
 
     /**
@@ -39,29 +32,29 @@ class SearchController extends Controller
         $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->searchService->searchEntities($searchOpts, 'all', $page, 20);
+        $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
 
         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,
         ]);
     }
 
-
     /**
      * Searches all entities within a book.
      */
     public function searchBook(Request $request, int $bookId)
     {
         $term = $request->get('term', '');
-        $results = $this->searchService->searchBook($bookId, $term);
-        return view('partials.entity-list', ['entities' => $results]);
+        $results = $this->searchRunner->searchBook($bookId, $term);
+
+        return view('entities.list', ['entities' => $results]);
     }
 
     /**
@@ -70,8 +63,9 @@ class SearchController extends Controller
     public function searchChapter(Request $request, int $chapterId)
     {
         $term = $request->get('term', '');
-        $results = $this->searchService->searchChapter($chapterId, $term);
-        return view('partials.entity-list', ['entities' => $results]);
+        $results = $this->searchRunner->searchChapter($chapterId, $term);
+
+        return view('entities.list', ['entities' => $results]);
     }
 
     /**
@@ -81,18 +75,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) .'}';
-            $entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
+            $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]);
     }
 
     /**
@@ -103,39 +97,8 @@ class SearchController extends Controller
         $type = $request->get('entity_type', null);
         $id = $request->get('entity_id', null);
 
-        $entity = Entity::getEntityInstance($type)->newQuery()->visible()->find($id);
-        if (!$entity) {
-            return $this->jsonError(trans('errors.entity_not_found'), 404);
-        }
-
-        $entities = [];
-
-        // Page in chapter
-        if ($entity->isA('page') && $entity->chapter) {
-            $entities = $entity->chapter->getVisiblePages();
-        }
-
-        // Page in book or chapter
-        if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
-            $entities = $entity->book->getDirectChildren();
-        }
-
-        // Book
-        // Gets just the books in a shelf if shelf is in context
-        if ($entity->isA('book')) {
-            $contextShelf = $this->entityContextManager->getContextualShelfForBook($entity);
-            if ($contextShelf) {
-                $entities = $contextShelf->visibleBooks()->get();
-            } else {
-                $entities = Book::visible()->get();
-            }
-        }
-
-        // Shelve
-        if ($entity->isA('bookshelf')) {
-            $entities = Bookshelf::visible()->get();
-        }
+        $entities = (new SiblingFetcher())->fetch($type, $id);
 
-        return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']);
+        return view('entities.list-basic', ['entities' => $entities, 'style' => 'compact']);
     }
 }
index feb6521f3a69a68bedbe4573d1edfac098da01aa..d9f172081ef2efb2851c0dacbc5937e2cc8c7e71 100644 (file)
@@ -1,9 +1,10 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
-use BookStack\Notifications\TestEmail;
 use BookStack\Uploads\ImageRepo;
-use BookStack\Uploads\ImageService;
 use Illuminate\Http\Request;
 
 class SettingController extends Controller
@@ -16,7 +17,6 @@ class SettingController extends Controller
     public function __construct(ImageRepo $imageRepo)
     {
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -31,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(),
         ]);
     }
 
@@ -49,10 +49,10 @@ class SettingController extends Controller
 
         // Cycles through posted settings and update them
         foreach ($request->all() as $name => $value) {
+            $key = str_replace('setting-', '', trim($name));
             if (strpos($name, 'setting-') !== 0) {
                 continue;
             }
-            $key = str_replace('setting-', '', trim($name));
             setting()->put($key, $value);
         }
 
@@ -70,67 +70,11 @@ class SettingController extends Controller
             setting()->remove('app-logo');
         }
 
+        $section = $request->get('section', '');
+        $this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
         $this->showSuccessNotification(trans('settings.settings_save_success'));
-        $redirectLocation = '/settings#' . $request->get('section', '');
-        return redirect(rtrim($redirectLocation, '#'));
-    }
-
-    /**
-     * Show the page for application maintenance.
-     */
-    public function showMaintenance()
-    {
-        $this->checkPermission('settings-manage');
-        $this->setPageTitle(trans('settings.maint'));
-
-        // Get application version
-        $version = trim(file_get_contents(base_path('version')));
-
-        return view('settings.maintenance', ['version' => $version]);
-    }
-
-    /**
-     * Action to clean-up images in the system.
-     */
-    public function cleanupImages(Request $request, ImageService $imageService)
-    {
-        $this->checkPermission('settings-manage');
-
-        $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
-        $dryRun = !($request->has('confirm'));
-
-        $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
-        $deleteCount = count($imagesToDelete);
-        if ($deleteCount === 0) {
-            $this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
-            return redirect('/settings/maintenance')->withInput();
-        }
-
-        if ($dryRun) {
-            session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
-        } else {
-            $this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
-        }
-
-        return redirect('/settings/maintenance#image-cleanup')->withInput();
-    }
+        $redirectLocation = '/settings#' . $section;
 
-    /**
-     * Action to send a test e-mail to the current user.
-     */
-    public function sendTestEmail()
-    {
-        $this->checkPermission('settings-manage');
-
-        try {
-            user()->notify(new TestEmail());
-            $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
-        } catch (\Exception $exception) {
-            $errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
-            $this->showErrorNotification($errorMessage);
-        }
-
-
-        return redirect('/settings/maintenance#image-cleanup')->withInput();
+        return redirect(rtrim($redirectLocation, '#'));
     }
 }
diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php
new file mode 100644 (file)
index 0000000..336e063
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Session;
+use Illuminate\Support\Str;
+
+class StatusController extends Controller
+{
+    /**
+     * Show the system status as a simple json page.
+     */
+    public function show()
+    {
+        $statuses = [
+            'database' => $this->trueWithoutError(function () {
+                return DB::table('migrations')->count() > 0;
+            }),
+            'cache' => $this->trueWithoutError(function () {
+                $rand = Str::random();
+                Cache::set('status_test', $rand);
+
+                return Cache::get('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);
+    }
+
+    /**
+     * Check the callable passed returns true and does not throw an exception.
+     */
+    protected function trueWithoutError(callable $test): bool
+    {
+        try {
+            return $test() === true;
+        } catch (\Exception $e) {
+            return false;
+        }
+    }
+}
index 8c6d6748fa5b79d41090e56f8fd1b1c73dab57c4..b0065af70f928e2f61cf8c8ec7d9050352503eb6 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;
 
     /**
@@ -14,7 +15,6 @@ class TagController extends Controller
     public function __construct(TagRepo $tagRepo)
     {
         $this->tagRepo = $tagRepo;
-        parent::__construct();
     }
 
     /**
@@ -24,6 +24,7 @@ class TagController extends Controller
     {
         $searchTerm = $request->get('search', null);
         $suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
+
         return response()->json($suggestions);
     }
 
@@ -35,6 +36,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 55675233c38af9552175d69b0eccb561e0034080..bdc25f79dc7050d91cf93027481badc04eef0623 100644 (file)
@@ -1,15 +1,16 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Api\ApiToken;
 use BookStack\Auth\User;
 use Illuminate\Http\Request;
-use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\Hash;
 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,7 +36,7 @@ class UserApiTokenController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $userId);
 
         $this->validate($request, [
-            'name' => 'required|max:250',
+            'name'       => 'required|max:250',
             'expires_at' => 'date_format:Y-m-d',
         ]);
 
@@ -42,10 +44,10 @@ class UserApiTokenController extends Controller
         $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(),
         ]);
 
@@ -57,6 +59,8 @@ class UserApiTokenController extends Controller
 
         session()->flash('api-token-secret:' . $token->id, $secret);
         $this->showSuccessNotification(trans('settings.user_api_token_create_success'));
+        $this->logActivity(ActivityType::API_TOKEN_CREATE, $token);
+
         return redirect($user->getEditUrl('/api-tokens/' . $token->id));
     }
 
@@ -69,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,
         ]);
     }
@@ -82,17 +86,19 @@ class UserApiTokenController extends Controller
     public function update(Request $request, int $userId, int $tokenId)
     {
         $this->validate($request, [
-            'name' => 'required|max:250',
+            '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));
     }
 
@@ -102,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,
         ]);
     }
@@ -117,6 +124,8 @@ class UserApiTokenController extends Controller
         $token->delete();
 
         $this->showSuccessNotification(trans('settings.user_api_token_delete_success'));
+        $this->logActivity(ActivityType::API_TOKEN_DELETE, $token);
+
         return redirect($user->getEditUrl('#api_tokens'));
     }
 
@@ -133,7 +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 5db3c59bd0f3b55220f815942e2d7189222d3138..2ee303f3fee46b2ea6be11810612e11593fe0698 100644 (file)
@@ -1,17 +1,22 @@
-<?php namespace BookStack\Http\Controllers;
+<?php
 
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\User;
 use BookStack\Auth\UserRepo;
+use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Uploads\ImageRepo;
+use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
 
 class UserController extends Controller
 {
-
     protected $user;
     protected $userRepo;
     protected $inviteService;
@@ -26,7 +31,6 @@ class UserController extends Controller
         $this->userRepo = $userRepo;
         $this->inviteService = $inviteService;
         $this->imageRepo = $imageRepo;
-        parent::__construct();
     }
 
     /**
@@ -36,13 +40,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]);
     }
 
@@ -54,20 +60,22 @@ class UserController extends Controller
         $this->checkPermission('users-manage');
         $authMethod = config('auth.method');
         $roles = $this->userRepo->getAllRoles();
+
         return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
     }
 
     /**
      * Store a newly created user in storage.
+     *
      * @throws UserUpdateException
-     * @throws \Illuminate\Validation\ValidationException
+     * @throws ValidationException
      */
     public function store(Request $request)
     {
         $this->checkPermission('users-manage');
         $validationRules = [
-            'name'             => 'required',
-            'email'            => 'required|email|unique:users,email'
+            'name'  => 'required',
+            'email' => 'required|email|unique:users,email',
         ];
 
         $authMethod = config('auth.method');
@@ -89,6 +97,7 @@ class UserController extends Controller
             $user->external_auth_id = $request->get('external_auth_id');
         }
 
+        $user->refreshSlug();
         $user->save();
 
         if ($sendInvite) {
@@ -102,6 +111,8 @@ class UserController extends Controller
 
         $this->userRepo->downloadAndAssignUserAvatar($user);
 
+        $this->logActivity(ActivityType::USER_CREATE, $user);
+
         return redirect('/settings/users');
     }
 
@@ -112,26 +123,31 @@ 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 \BookStack\Exceptions\ImageUploadException
-     * @throws \Illuminate\Validation\ValidationException
+     * @throws ImageUploadException
+     * @throws ValidationException
      */
     public function update(Request $request, int $id)
     {
@@ -155,6 +171,11 @@ class UserController extends Controller
             $user->email = $request->get('email');
         }
 
+        // Refresh the slug if the user's name has changed
+        if ($user->isDirty('name')) {
+            $user->refreshSlug();
+        }
+
         // Role updates
         if (userCan('users-manage') && $request->filled('roles')) {
             $roles = $request->get('roles');
@@ -187,15 +208,17 @@ class UserController extends Controller
             $user->image_id = $image->id;
         }
 
-        // Delete the profile image if set to
+        // Delete the profile image if reset option is in request
         if ($request->has('profile_image_reset')) {
             $this->imageRepo->destroyImage($user->avatar);
         }
 
         $user->save();
         $this->showSuccessNotification(trans('settings.users_edit_success'));
+        $this->logActivity(ActivityType::USER_UPDATE, $user);
 
         $redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
+
         return redirect($redirectUrl);
     }
 
@@ -208,55 +231,42 @@ 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
+     *
+     * @throws Exception
      */
-    public function destroy(int $id)
+    public function destroy(Request $request, int $id)
     {
         $this->preventAccessInDemoMode();
         $this->checkPermissionOrCurrentUser('users-manage', $id);
 
         $user = $this->userRepo->getById($id);
+        $newOwnerId = $request->get('new_owner_id', null);
 
         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());
         }
 
-        $this->userRepo->destroy($user);
+        $this->userRepo->destroy($user, $newOwnerId);
         $this->showSuccessNotification(trans('settings.users_delete_success'));
+        $this->logActivity(ActivityType::USER_DELETE, $user);
 
         return redirect('/settings/users');
     }
 
-    /**
-     * Show the user profile page
-     */
-    public function showProfilePage($id)
-    {
-        $user = $this->userRepo->getById($id);
-
-        $userActivity = $this->userRepo->getActivity($user);
-        $recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5);
-        $assetCounts = $this->userRepo->getAssetCounts($user);
-
-        return view('users.profile', [
-            'user' => $user,
-            'activity' => $userActivity,
-            'recentlyCreated' => $recentlyCreated,
-            'assetCounts' => $assetCounts
-        ]);
-    }
-
     /**
      * Update the user's preferred book-list display setting.
      */
@@ -305,10 +315,11 @@ class UserController extends Controller
      */
     public function changeSort(Request $request, string $id, string $type)
     {
-        $validSortTypes = ['books', 'bookshelves'];
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
+
         return $this->changeListSort($id, $request, $type);
     }
 
@@ -319,6 +330,7 @@ class UserController extends Controller
     {
         $enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
         setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
+
         return redirect()->back();
     }
 
@@ -330,14 +342,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);
     }
 
     /**
@@ -348,7 +361,7 @@ class UserController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $userId);
 
         $sort = $request->get('sort');
-        if (!in_array($sort, ['name', 'created_at', 'updated_at'])) {
+        if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default'])) {
             $sort = 'name';
         }
 
diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php
new file mode 100644 (file)
index 0000000..09ae4c1
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Auth\UserRepo;
+
+class UserProfileController extends Controller
+{
+    /**
+     * Show the user profile page.
+     */
+    public function show(UserRepo $repo, string $slug)
+    {
+        $user = $repo->getBySlug($slug);
+
+        $userActivity = $repo->getActivity($user);
+        $recentlyCreated = $repo->getRecentlyCreated($user, 5);
+        $assetCounts = $repo->getAssetCounts($user);
+
+        return view('users.profile', [
+            'user'            => $user,
+            'activity'        => $userActivity,
+            'recentlyCreated' => $recentlyCreated,
+            'assetCounts'     => $assetCounts,
+        ]);
+    }
+}
diff --git a/app/Http/Controllers/UserSearchController.php b/app/Http/Controllers/UserSearchController.php
new file mode 100644 (file)
index 0000000..f7ed9e5
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Auth\User;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\Request;
+
+class UserSearchController extends Controller
+{
+    /**
+     * Search users in the system, with the response formatted
+     * for use in a select-style list.
+     */
+    public function forSelect(Request $request)
+    {
+        $search = $request->get('search', '');
+        $query = User::query()->orderBy('name', 'desc')
+            ->take(20);
+
+        if (!empty($search)) {
+            $query->where(function (Builder $query) use ($search) {
+                $query->where('email', 'like', '%' . $search . '%')
+                    ->orWhere('name', 'like', '%' . $search . '%');
+            });
+        }
+
+        $users = $query->get();
+
+        return view('form.user-select-list', compact('users'));
+    }
+}
index a0c45ea896acdb18aed235ec7b51b14133c5a32f..a98528d0f9ebc747a4224974a3500c573e67f0ac 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http;
+<?php
+
+namespace BookStack\Http;
 
 use Illuminate\Foundation\Http\Kernel as HttpKernel;
 
@@ -22,19 +24,22 @@ class Kernel extends HttpKernel
      */
     protected $middlewareGroups = [
         'web' => [
+            \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\CheckEmailConfirmed::class,
+            \BookStack\Http\Middleware\RunThemeActions::class,
             \BookStack\Http\Middleware\Localization::class,
-            \BookStack\Http\Middleware\GlobalViewData::class,
         ],
         'api' => [
             \BookStack\Http\Middleware\ThrottleApiRequests::class,
             \BookStack\Http\Middleware\EncryptCookies::class,
             \BookStack\Http\Middleware\StartSessionIfCookieExists::class,
             \BookStack\Http\Middleware\ApiAuthenticate::class,
+            \BookStack\Http\Middleware\CheckEmailConfirmed::class,
         ],
     ];
 
@@ -45,10 +50,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..bc584d3c5a4660e79af15c75352c9ee57042b5f5 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')) {
                 throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
             }
+
             return;
         }
 
@@ -48,7 +47,6 @@ class ApiAuthenticate
 
         // Validate the token and it's users API access
         auth()->authenticate();
-        $this->ensureEmailConfirmedIfRequested();
     }
 
     /**
@@ -58,9 +56,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 9a8affa8842fbe7e0a944462f5ee24770d752a79..a320291122b6f632b0bc36a8e96d15e42472a5f8 100644 (file)
@@ -7,43 +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'));
         }
 
-        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..4a6a064
--- /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                          $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 4b17328..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;
-    }
-}
\ No newline at end of file
diff --git a/app/Http/Middleware/GlobalViewData.php b/app/Http/Middleware/GlobalViewData.php
deleted file mode 100644 (file)
index bc132df..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php namespace BookStack\Http\Middleware;
-
-use Closure;
-use Illuminate\Http\Request;
-
-/**
- * Class GlobalViewData
- * Sets up data that is accessible to any view rendered by the web routes.
- */
-class GlobalViewData
-{
-
-    /**
-     * Handle an incoming request.
-     *
-     * @param Request $request
-     * @param Closure $next
-     * @return mixed
-     */
-    public function handle(Request $request, Closure $next)
-    {
-        view()->share('signedIn', auth()->check());
-        view()->share('currentUser', user());
-
-        return $next($request);
-    }
-}
index d24e8a9b5b88c8f8cb0b08f60a05506942561021..e824651469b5aa30493c5004ed1affdd6ac89ac8 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Middleware;
+<?php
+
+namespace BookStack\Http\Middleware;
 
 use Carbon\Carbon;
 use Closure;
@@ -6,50 +8,56 @@ use Illuminate\Http\Request;
 
 class Localization
 {
-
     /**
-     * Array of right-to-left locales
-     * @var array
+     * Array of right-to-left locales.
      */
     protected $rtlLocales = ['ar', 'he'];
 
     /**
      * Map of BookStack locale names to best-estimate system locale names.
-     * @var array
      */
     protected $localeMap = [
-        'ar' => 'ar',
-        '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',
-        'it' => 'it_IT',
-        'ja' => 'ja',
-        'ko' => 'ko_KR',
-        'nl' => 'nl_NL',
-        'pl' => 'pl_PL',
-        'pt' => 'pl_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',
+        '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)
@@ -57,12 +65,7 @@ class Localization
         $defaultLang = config('app.locale');
         config()->set('app.default_locale', $defaultLang);
 
-        if (user()->isDefault() && config('app.auto_detect_locale')) {
-            $locale = $this->autoDetectLocale($request, $defaultLang);
-        } else {
-            $locale = setting()->getUser(user(), 'language', $defaultLang);
-        }
-
+        $locale = $this->getUserLocale($request, $defaultLang);
         config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
 
         // Set text direction
@@ -73,17 +76,33 @@ class Localization
         app()->setLocale($locale);
         Carbon::setLocale($locale);
         $this->setSystemDateLocale($locale);
+
         return $next($request);
     }
 
+    /**
+     * Get the locale specifically for the currently logged in user if available.
+     */
+    protected function getUserLocale(Request $request, string $default): string
+    {
+        try {
+            $user = user();
+        } catch (\Exception $exception) {
+            return $default;
+        }
+
+        if ($user->isDefault() && config('app.auto_detect_locale')) {
+            return $this->autoDetectLocale($request, $default);
+        }
+
+        return setting()->getUser($user, 'language', $default);
+    }
+
     /**
      * Autodetect the visitors locale by matching locales in their headers
      * against the locales supported by BookStack.
-     * @param Request $request
-     * @param string $default
-     * @return string
      */
-    protected function autoDetectLocale(Request $request, string $default)
+    protected function autoDetectLocale(Request $request, string $default): string
     {
         $availableLocales = config('app.locales');
         foreach ($request->getLanguages() as $lang) {
@@ -91,15 +110,14 @@ class Localization
                 return $lang;
             }
         }
+
         return $default;
     }
 
     /**
-     * Get the ISO version of a BookStack language name
-     * @param  string $locale
-     * @return string
+     * Get the ISO version of a BookStack language name.
      */
-    public function getLocaleIso(string $locale)
+    public function getLocaleIso(string $locale): string
     {
         return $this->localeMap[$locale] ?? $locale;
     }
@@ -107,7 +125,6 @@ class Localization
     /**
      * Set the system date locale for localized date formatting.
      * Will try both the standard locale name and the UTF8 variant.
-     * @param string $locale
      */
     protected function setSystemDateLocale(string $locale)
     {
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);
-    }
-}
index c27df7af4f5434bdbf7d208fbe98f82ddde78bfb..6853809ea9d03b5db7a74a817c6f256ef00d832a 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Http\Middleware;
+<?php
+
+namespace BookStack\Http\Middleware;
 
 use Closure;
 use Illuminate\Contracts\Auth\Guard;
@@ -15,7 +17,8 @@ class RedirectIfAuthenticated
     /**
      * Create a new filter instance.
      *
-     * @param  Guard $auth
+     * @param Guard $auth
+     *
      * @return void
      */
     public function __construct(Guard $auth)
@@ -26,8 +29,9 @@ class RedirectIfAuthenticated
     /**
      * 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)
diff --git a/app/Http/Middleware/RunThemeActions.php b/app/Http/Middleware/RunThemeActions.php
new file mode 100644 (file)
index 0000000..5e727da
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace BookStack\Http\Middleware;
+
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
+use Closure;
+
+class RunThemeActions
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure                 $next
+     *
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        $earlyResponse = Theme::dispatch(ThemeEvents::WEB_MIDDLEWARE_BEFORE, $request);
+        if (!is_null($earlyResponse)) {
+            return $earlyResponse;
+        }
+
+        $response = $next($request);
+        $response = Theme::dispatch(ThemeEvents::WEB_MIDDLEWARE_AFTER, $request, $response) ?? $response;
+
+        return $response;
+    }
+}
index d08840cd1f857415e76a2496b200c22873c30d9f..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.
      */
@@ -14,5 +13,4 @@ class ThrottleApiRequests extends Middleware
     {
         return (int) config('api.requests_per_minute');
     }
-
-}
\ No newline at end of file
+}
index 7b01d0aab0de906a01be4afe31a7f82b937b429e..3f8b32eb2cbe7105abde5c72487cbaaceccde723 100644 (file)
@@ -24,8 +24,10 @@ class TrustProxies extends Middleware
 
     /**
      * 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 007564eb3ada1826e38e25a1ca62ff1a04e855ba..a2e7f1dc117c8325432b563313779d9c819e81df 100644 (file)
@@ -20,6 +20,6 @@ class VerifyCsrfToken extends Middleware
      */
     protected $except = [
         'saml2/*',
-        'openid/*'
+        'openid/*',
     ];
 }
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/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;
+}
diff --git a/app/Interfaces/Loggable.php b/app/Interfaces/Loggable.php
new file mode 100644 (file)
index 0000000..923f64b
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace BookStack\Interfaces;
+
+interface Loggable
+{
+    /**
+     * Get the string descriptor for this item.
+     */
+    public function logDescriptor(): string;
+}
diff --git a/app/Interfaces/Sluggable.php b/app/Interfaces/Sluggable.php
new file mode 100644 (file)
index 0000000..24ee1ba
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+namespace BookStack\Interfaces;
+
+use Illuminate\Database\Eloquent\Builder;
+
+/**
+ * Interface Sluggable.
+ *
+ * Assigned to models that can have slugs.
+ * Must have the below properties.
+ *
+ * @property int    $id
+ * @property string $name
+ *
+ * @method Builder newQuery
+ */
+interface Sluggable
+{
+    /**
+     * Regenerate the slug for this model.
+     */
+    public function refreshSlug(): string;
+}
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..8520060f442efe78beed4a7a8159302e1f39f896 100644 (file)
@@ -1,14 +1,17 @@
-<?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)
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..b0dc9afac3f836d11e9159ed85ab393c0fa7e299 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Notifications;
+<?php
+
+namespace BookStack\Notifications;
 
 class UserInvite extends MailNotification
 {
@@ -6,6 +8,7 @@ class UserInvite extends MailNotification
 
     /**
      * Create a new notification instance.
+     *
      * @param string $token
      */
     public function __construct($token)
@@ -16,12 +19,14 @@ class UserInvite 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.user_invite_email_subject', $appName))
                 ->greeting(trans('auth.user_invite_email_greeting', $appName))
diff --git a/app/Ownable.php b/app/Ownable.php
deleted file mode 100644 (file)
index bf24fad..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php namespace BookStack;
-
-use BookStack\Auth\User;
-
-/**
- * @property int created_by
- * @property int updated_by
- */
-abstract class Ownable extends Model
-{
-    /**
-     * Relation for the user that created this entity.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
-    public function createdBy()
-    {
-        return $this->belongsTo(User::class, 'created_by');
-    }
-
-    /**
-     * Relation for the user that updated this entity.
-     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
-     */
-    public function updatedBy()
-    {
-        return $this->belongsTo(User::class, 'updated_by');
-    }
-
-    /**
-     * Gets the class name.
-     * @return string
-     */
-    public static function getClassName()
-    {
-        return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]);
-    }
-}
index 1cc3e09c22dc6677a517af03ddbbd0efeb3eac3b..8334bb179ae73b4e08982271d37dd4b0cf2ee971 100644 (file)
@@ -1,19 +1,25 @@
-<?php namespace BookStack\Providers;
+<?php
 
-use Blade;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+namespace BookStack\Providers;
+
+use BookStack\Auth\Access\LoginService;
+use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Entities\BreadcrumbsViewComposer;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
+use BookStack\Util\CspService;
+use Illuminate\Contracts\Cache\Repository;
 use Illuminate\Database\Eloquent\Relations\Relation;
+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 Schema;
-use URL;
-use Validator;
+use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
 
 class AppServiceProvider extends ServiceProvider
 {
@@ -32,43 +38,24 @@ class AppServiceProvider extends ServiceProvider
             URL::forceScheme($isHttps ? 'https' : 'http');
         }
 
-        // Custom validation methods
-        Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
-            $validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
-            return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
-        });
-
-        Validator::extend('no_double_extension', function ($attribute, $value, $parameters, $validator) {
-            $uploadName = $value->getClientOriginalName();
-            return substr_count($uploadName, '.') < 2;
-        });
-
         // Custom blade view directives
         Blade::directive('icon', function ($expression) {
             return "<?php echo icon($expression); ?>";
         });
 
-        Blade::directive('exposeTranslations', function ($expression) {
-            return "<?php \$__env->startPush('translations'); ?>" .
-                "<?php foreach({$expression} as \$key): ?>" .
-                '<meta name="translation" key="<?php echo e($key); ?>" value="<?php echo e(trans($key)); ?>">' . "\n" .
-                "<?php endforeach; ?>" .
-                '<?php $__env->stopPush(); ?>';
-        });
-
         // Allow longer string lengths after upgrade to utf8mb4
         Schema::defaultStringLength(191);
 
         // 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);
     }
 
     /**
@@ -79,7 +66,15 @@ class AppServiceProvider extends ServiceProvider
     public function register()
     {
         $this->app->singleton(SettingService::class, function ($app) {
-            return new SettingService($app->make(Setting::class), $app->make('Illuminate\Contracts\Cache\Repository'));
+            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), $app->make(LoginService::class));
+        });
+
+        $this->app->singleton(CspService::class, function ($app) {
+            return new CspService();
         });
     }
 }
index 653a292488995564036761e609b15a81f999a5c4..cd90cc849a895f5a2fa8d38c049eb8e45f993917 100644 (file)
@@ -2,16 +2,16 @@
 
 namespace BookStack\Providers;
 
-use Auth;
 use BookStack\Api\ApiTokenGuard;
 use BookStack\Auth\Access\ExternalBaseUserProvider;
 use BookStack\Auth\Access\Guards\LdapSessionGuard;
 use BookStack\Auth\Access\Guards\Saml2SessionGuard;
 use BookStack\Auth\Access\Guards\OpenIdSessionGuard;
 use BookStack\Auth\Access\LdapService;
+use BookStack\Auth\Access\LoginService;
 use BookStack\Auth\Access\OpenIdService;
 use BookStack\Auth\Access\RegistrationService;
-use BookStack\Auth\UserRepo;
+use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\ServiceProvider;
 
 class AuthServiceProvider extends ServiceProvider
@@ -24,15 +24,16 @@ class AuthServiceProvider extends ServiceProvider
     public function boot()
     {
         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]
             );
@@ -40,10 +41,11 @@ class AuthServiceProvider extends ServiceProvider
 
         Auth::extend('saml2-session', function ($app, $name, array $config) {
             $provider = Auth::createUserProvider($config['provider']);
+
             return new Saml2SessionGuard(
                 $name,
                 $provider,
-                $this->app['session.store'],
+                $app['session.store'],
                 $app[RegistrationService::class]
             );
         });
index b4158187cd5fe5225b68aa013d033d67d05edb30..ca86b6607e2d2875ad006f884821616c180f6201 100644 (file)
@@ -3,9 +3,8 @@
 namespace BookStack\Providers;
 
 use BookStack\Actions\ActivityService;
-use BookStack\Actions\ViewService;
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Settings\SettingService;
+use BookStack\Theming\ThemeService;
 use BookStack\Uploads\ImageService;
 use Illuminate\Support\ServiceProvider;
 
@@ -32,14 +31,6 @@ class CustomFacadeProvider extends ServiceProvider
             return $this->app->make(ActivityService::class);
         });
 
-        $this->app->singleton('views', function () {
-            return $this->app->make(ViewService::class);
-        });
-
-        $this->app->singleton('setting', function () {
-            return $this->app->make(SettingService::class);
-        });
-
         $this->app->singleton('images', function () {
             return $this->app->make(ImageService::class);
         });
@@ -47,5 +38,9 @@ class CustomFacadeProvider extends ServiceProvider
         $this->app->singleton('permissions', function () {
             return $this->app->make(PermissionService::class);
         });
+
+        $this->app->singleton('theme', function () {
+            return $this->app->make(ThemeService::class);
+        });
     }
 }
diff --git a/app/Providers/CustomValidationServiceProvider.php b/app/Providers/CustomValidationServiceProvider.php
new file mode 100644 (file)
index 0000000..c54f48c
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace BookStack\Providers;
+
+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);
+        });
+
+        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 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..b60443a452895fc19a8c4a5dcffdfeb03f339b52 100644 (file)
@@ -3,7 +3,7 @@
 namespace BookStack\Providers;
 
 use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
-use Route;
+use Illuminate\Support\Facades\Route;
 
 class RouteServiceProvider extends ServiceProvider
 {
@@ -36,6 +36,7 @@ class RouteServiceProvider extends ServiceProvider
         $this->mapWebRoutes();
         $this->mapApiRoutes();
     }
+
     /**
      * Define the "web" routes for the application.
      *
@@ -47,11 +48,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,8 +65,8 @@ 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');
         });
diff --git a/app/Providers/ThemeServiceProvider.php b/app/Providers/ThemeServiceProvider.php
new file mode 100644 (file)
index 0000000..54c8388
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace BookStack\Providers;
+
+use BookStack\Theming\ThemeEvents;
+use BookStack\Theming\ThemeService;
+use Illuminate\Support\ServiceProvider;
+
+class ThemeServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     *
+     * @return void
+     */
+    public function register()
+    {
+        $this->app->singleton(ThemeService::class, function ($app) {
+            return new ThemeService();
+        });
+    }
+
+    /**
+     * Bootstrap services.
+     *
+     * @return void
+     */
+    public function boot()
+    {
+        $themeService = $this->app->make(ThemeService::class);
+        $themeService->readThemeActions();
+        $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
+    }
+}
index 9ff607afe914a8bc0d6c2e6fea620a2bba092f90..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()
@@ -17,5 +19,4 @@ class TranslationServiceProvider extends BaseProvider
             return new FileLoader($app['files'], $app['path.lang']);
         });
     }
-
-}
\ No newline at end of file
+}
index 1a52920eeb73cc6c88e78ad7ec68961ddd30e8cf..c0492e6c1f7ca9133de70ba042b4d0b5823a6038 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace BookStack\Settings;
+<?php
+
+namespace BookStack\Settings;
 
 use BookStack\Model;
 
index 1c053b3848ea779d480adea01834dce0059d629d..f2c4c8305c47c2db227a79456e880422171dc500 100644 (file)
@@ -1,5 +1,8 @@
-<?php namespace BookStack\Settings;
+<?php
 
+namespace BookStack\Settings;
+
+use BookStack\Auth\User;
 use Illuminate\Contracts\Cache\Repository as Cache;
 
 /**
@@ -9,7 +12,6 @@ use Illuminate\Contracts\Cache\Repository as Cache;
  */
 class SettingService
 {
-
     protected $setting;
     protected $cache;
     protected $localCache = [];
@@ -18,8 +20,6 @@ class SettingService
 
     /**
      * SettingService constructor.
-     * @param Setting $setting
-     * @param Cache   $cache
      */
     public function __construct(Setting $setting, Cache $cache)
     {
@@ -30,13 +30,10 @@ class SettingService
     /**
      * Gets a setting from the database,
      * If not found, Returns default, Which is false by default.
-     * @param             $key
-     * @param string|bool $default
-     * @return bool|string
      */
-    public function get($key, $default = false)
+    public function get(string $key, $default = null)
     {
-        if ($default === false) {
+        if (is_null($default)) {
             $default = config('setting-defaults.' . $key, false);
         }
 
@@ -44,47 +41,43 @@ class SettingService
             return $this->localCache[$key];
         }
 
-        $value = $this->getValueFromStore($key, $default);
+        $value = $this->getValueFromStore($key) ?? $default;
         $formatted = $this->formatValue($value, $default);
         $this->localCache[$key] = $formatted;
+
         return $formatted;
     }
 
     /**
      * Get a value from the session instead of the main store option.
-     * @param $key
-     * @param bool $default
-     * @return mixed
      */
-    protected function getFromSession($key, $default = false)
+    protected function getFromSession(string $key, $default = false)
     {
         $value = session()->get($key, $default);
-        $formatted = $this->formatValue($value, $default);
-        return $formatted;
+
+        return $this->formatValue($value, $default);
     }
 
     /**
      * Get a user-specific setting from the database or cache.
-     * @param \BookStack\Auth\User $user
-     * @param $key
-     * @param bool $default
-     * @return bool|string
      */
-    public function getUser($user, $key, $default = false)
+    public function getUser(User $user, string $key, $default = null)
     {
+        if (is_null($default)) {
+            $default = config('setting-defaults.user.' . $key, false);
+        }
+
         if ($user->isDefault()) {
             return $this->getFromSession($key, $default);
         }
+
         return $this->get($this->userKey($user->id, $key), $default);
     }
 
     /**
      * Get a value for the current logged-in user.
-     * @param $key
-     * @param bool $default
-     * @return bool|string
      */
-    public function getForCurrentUser($key, $default = false)
+    public function getForCurrentUser(string $key, $default = null)
     {
         return $this->getUser(user(), $key, $default);
     }
@@ -92,11 +85,9 @@ class SettingService
     /**
      * Gets a setting value from the cache or database.
      * Looks at the system defaults if not cached or in database.
-     * @param $key
-     * @param $default
-     * @return mixed
+     * Returns null if nothing is found.
      */
-    protected function getValueFromStore($key, $default)
+    protected function getValueFromStore(string $key)
     {
         // Check the cache
         $cacheKey = $this->cachePrefix . $key;
@@ -109,18 +100,23 @@ class SettingService
         $settingObject = $this->getSettingObjectByKey($key);
         if ($settingObject !== null) {
             $value = $settingObject->value;
+
+            if ($settingObject->type === 'array') {
+                $value = json_decode($value, true) ?? [];
+            }
+
             $this->cache->forever($cacheKey, $value);
+
             return $value;
         }
 
-        return $default;
+        return null;
     }
 
     /**
      * Clear an item from the cache completely.
-     * @param $key
      */
-    protected function clearFromCache($key)
+    protected function clearFromCache(string $key)
     {
         $cacheKey = $this->cachePrefix . $key;
         $this->cache->forget($cacheKey);
@@ -130,18 +126,14 @@ class SettingService
     }
 
     /**
-     * Format a settings value
-     * @param $value
-     * @param $default
-     * @return mixed
+     * Format a settings value.
      */
     protected function formatValue($value, $default)
     {
         // Change string booleans to actual booleans
         if ($value === 'true') {
             $value = true;
-        }
-        if ($value === 'false') {
+        } elseif ($value === 'false') {
             $value = false;
         }
 
@@ -149,104 +141,107 @@ class SettingService
         if ($value === '') {
             $value = $default;
         }
+
         return $value;
     }
 
     /**
      * Checks if a setting exists.
-     * @param $key
-     * @return bool
      */
-    public function has($key)
+    public function has(string $key): bool
     {
         $setting = $this->getSettingObjectByKey($key);
-        return $setting !== null;
-    }
 
-    /**
-     * Check if a user setting is in the database.
-     * @param $key
-     * @return bool
-     */
-    public function hasUser($key)
-    {
-        return $this->has($this->userKey($key));
+        return $setting !== null;
     }
 
     /**
      * Add a setting to the database.
-     * @param $key
-     * @param $value
-     * @return bool
+     * Values can be an array or a string.
      */
-    public function put($key, $value)
+    public function put(string $key, $value): bool
     {
-        $setting = $this->setting->firstOrNew([
-            'setting_key' => $key
+        $setting = $this->setting->newQuery()->firstOrNew([
+            'setting_key' => $key,
         ]);
+        $setting->type = 'string';
+
+        if (is_array($value)) {
+            $setting->type = 'array';
+            $value = $this->formatArrayValue($value);
+        }
+
         $setting->value = $value;
         $setting->save();
         $this->clearFromCache($key);
+
         return true;
     }
 
+    /**
+     * Format an array to be stored as a setting.
+     * Array setting types are expected to be a flat array of child key=>value array items.
+     * This filters out any child items that are empty.
+     */
+    protected function formatArrayValue(array $value): string
+    {
+        $values = collect($value)->values()->filter(function (array $item) {
+            return count(array_filter($item)) > 0;
+        });
+
+        return json_encode($values);
+    }
+
     /**
      * Put a user-specific setting into the database.
-     * @param \BookStack\Auth\User $user
-     * @param $key
-     * @param $value
-     * @return bool
      */
-    public function putUser($user, $key, $value)
+    public function putUser(User $user, string $key, string $value): bool
     {
         if ($user->isDefault()) {
-            return session()->put($key, $value);
+            session()->put($key, $value);
+
+            return true;
         }
+
         return $this->put($this->userKey($user->id, $key), $value);
     }
 
     /**
      * Convert a setting key into a user-specific key.
-     * @param $key
-     * @return string
      */
-    protected function userKey($userId, $key = '')
+    protected function userKey(string $userId, string $key = ''): string
     {
         return 'user:' . $userId . ':' . $key;
     }
 
     /**
      * Removes a setting from the database.
-     * @param $key
-     * @return bool
      */
-    public function remove($key)
+    public function remove(string $key): void
     {
         $setting = $this->getSettingObjectByKey($key);
         if ($setting) {
             $setting->delete();
         }
         $this->clearFromCache($key);
-        return true;
     }
 
     /**
      * Delete settings for a given user id.
-     * @param $userId
-     * @return mixed
      */
-    public function deleteUserSettings($userId)
+    public function deleteUserSettings(string $userId)
     {
-        return $this->setting->where('setting_key', 'like', $this->userKey($userId) . '%')->delete();
+        return $this->setting->newQuery()
+            ->where('setting_key', 'like', $this->userKey($userId) . '%')
+            ->delete();
     }
 
     /**
      * Gets a setting model from the database for the given key.
-     * @param $key
-     * @return mixed
      */
-    protected function getSettingObjectByKey($key)
+    protected function getSettingObjectByKey(string $key): ?Setting
     {
-        return $this->setting->where('setting_key', '=', $key)->first();
+        return $this->setting->newQuery()
+            ->where('setting_key', '=', $key)->first();
     }
 }
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', '');
+    }
+}
diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php
new file mode 100644 (file)
index 0000000..1965556
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+namespace BookStack\Theming;
+
+/**
+ * The ThemeEvents used within BookStack.
+ *
+ * This file details the events that BookStack may fire via the custom
+ * theme system, including event names, parameters and expected return types.
+ *
+ * This system is regarded as semi-stable.
+ * We'll look to fix issues with it or migrate old event types but
+ * events and their signatures may change in new versions of BookStack.
+ * We'd advise testing any usage of these events upon upgrade.
+ */
+class ThemeEvents
+{
+    /**
+     * Application boot-up.
+     * After main services are registered.
+     *
+     * @param \BookStack\Application $app
+     */
+    const APP_BOOT = 'app_boot';
+
+    /**
+     * Web before middleware action.
+     * Runs before the request is handled but after all other middleware apart from those
+     * 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
+     */
+    const WEB_MIDDLEWARE_BEFORE = 'web_middleware_before';
+
+    /**
+     * Web after middleware action.
+     * 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\Response|Symfony\Component\HttpFoundation\BinaryFileResponse $response
+     * @returns \Illuminate\Http\Response|null
+     */
+    const WEB_MIDDLEWARE_AFTER = 'web_middleware_after';
+
+    /**
+     * Auth login event.
+     * 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 \BookStack\Auth\User $user
+     */
+    const AUTH_LOGIN = 'auth_login';
+
+    /**
+     * Auth register event.
+     * 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 \BookStack\Auth\User $user
+     */
+    const AUTH_REGISTER = 'auth_register';
+
+    /**
+     * Commonmark environment configure.
+     * 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';
+}
diff --git a/app/Theming/ThemeService.php b/app/Theming/ThemeService.php
new file mode 100644 (file)
index 0000000..602abaf
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+namespace BookStack\Theming;
+
+use BookStack\Auth\Access\SocialAuthService;
+
+class ThemeService
+{
+    protected $listeners = [];
+
+    /**
+     * Listen to a given custom theme event,
+     * setting up the action to be ran when the event occurs.
+     */
+    public function listen(string $event, callable $action)
+    {
+        if (!isset($this->listeners[$event])) {
+            $this->listeners[$event] = [];
+        }
+
+        $this->listeners[$event][] = $action;
+    }
+
+    /**
+     * Dispatch the given event name.
+     * Runs any registered listeners for that event name,
+     * passing all additional variables to the listener action.
+     *
+     * 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)
+    {
+        foreach ($this->listeners[$event] ?? [] as $action) {
+            $result = call_user_func_array($action, $args);
+            if (!is_null($result)) {
+                return $result;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Read any actions from the set theme path if the 'functions.php' file exists.
+     */
+    public function readThemeActions()
+    {
+        $themeActionsFile = theme_path('functions.php');
+        if ($themeActionsFile && file_exists($themeActionsFile)) {
+            require $themeActionsFile;
+        }
+    }
+
+    /**
+     * @see SocialAuthService::addSocialDriver
+     */
+    public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null)
+    {
+        $socialAuthService = app()->make(SocialAuthService::class);
+        $socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
+    }
+}
diff --git a/app/Traits/HasCreatorAndUpdater.php b/app/Traits/HasCreatorAndUpdater.php
new file mode 100644 (file)
index 0000000..a48936b
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace BookStack\Traits;
+
+use BookStack\Auth\User;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int created_by
+ * @property int updated_by
+ */
+trait HasCreatorAndUpdater
+{
+    /**
+     * Relation for the user that created this entity.
+     */
+    public function createdBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
+
+    /**
+     * Relation for the user that updated this entity.
+     */
+    public function updatedBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'updated_by');
+    }
+}
diff --git a/app/Traits/HasOwner.php b/app/Traits/HasOwner.php
new file mode 100644 (file)
index 0000000..6700ff4
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+namespace BookStack\Traits;
+
+use BookStack\Auth\User;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int owned_by
+ */
+trait HasOwner
+{
+    /**
+     * Relation for the user that owns this entity.
+     */
+    public function ownedBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'owned_by');
+    }
+}
index f0f895da55c05ca8598a5d8f9bc6eb39d13b44b0..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,11 +24,13 @@ 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);
         }
 
         return $this->loadNamespaced($locale, $group, $namespace);
     }
-}
\ No newline at end of file
+}
index 3f0b447df7388a573602396d052bce2e5c5bca7a..5acd4f141bb2dfcac8f118bde2bdcf11b97b2af8 100644 (file)
@@ -1,14 +1,29 @@
-<?php namespace BookStack\Uploads;
+<?php
 
-use BookStack\Entities\Page;
-use BookStack\Ownable;
+namespace BookStack\Uploads;
 
-class Attachment extends Ownable
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+use BookStack\Traits\HasCreatorAndUpdater;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * @property int id
+ * @property string name
+ * @property string path
+ * @property string extension
+ * @property ?Page page
+ * @property bool external
+ */
+class Attachment extends Model
 {
+    use HasCreatorAndUpdater;
+
     protected $fillable = ['name', 'order'];
 
     /**
      * Get the downloadable file name for this upload.
+     *
      * @return mixed|string
      */
     public function getFileName()
@@ -16,27 +31,43 @@ class Attachment extends Ownable
         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');
     }
 
     /**
      * Get the url of this file.
-     * @return string
      */
-    public function getUrl()
+    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' : ''));
+    }
+
+    /**
+     * Generate a HTML link to this attachment.
+     */
+    public function htmlLink(): string
+    {
+        return '<a target="_blank" href="' . e($this->getUrl()) . '">' . e($this->name) . '</a>';
+    }
+
+    /**
+     * Generate a markdown link to this attachment.
+     */
+    public function markdownLink(): string
+    {
+        return '[' . $this->name . '](' . $this->getUrl() . ')';
     }
 }
index ae4fb6e967160787e1c46dde0e442228386f9af9..b4cb1b88b15e26b0d982174b97fa5b80ed13c385 100644 (file)
@@ -1,18 +1,32 @@
-<?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\FileNotFoundException;
+use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class AttachmentService extends UploadService
+class AttachmentService
 {
+    protected $fileSystem;
+
+    /**
+     * AttachmentService constructor.
+     */
+    public function __construct(FileSystem $fileSystem)
+    {
+        $this->fileSystem = $fileSystem;
+    }
 
     /**
      * Get the storage that will be used for storing files.
-     * @return \Illuminate\Contracts\Filesystem\Filesystem
      */
-    protected function getStorage()
+    protected function getStorage(): FileSystemInstance
     {
         $storageType = config('filesystems.attachments');
 
@@ -26,21 +40,23 @@ class AttachmentService extends UploadService
 
     /**
      * 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);
     }
 
     /**
      * Store a new attachment upon user upload.
+     *
      * @param UploadedFile $uploadedFile
-     * @param int $page_id
-     * @return Attachment
+     * @param int          $page_id
+     *
      * @throws FileUploadException
+     *
+     * @return Attachment
      */
     public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
     {
@@ -49,13 +65,13 @@ class AttachmentService extends UploadService
         $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
 
         $attachment = Attachment::forceCreate([
-            'name' => $attachmentName,
-            'path' => $attachmentPath,
-            'extension' => $uploadedFile->getClientOriginalExtension(),
+            'name'        => $attachmentName,
+            'path'        => $attachmentPath,
+            'extension'   => $uploadedFile->getClientOriginalExtension(),
             '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,
         ]);
 
         return $attachment;
@@ -64,10 +80,13 @@ class AttachmentService extends UploadService
     /**
      * Store a upload, saving to a file and deleting any existing uploads
      * attached to that file.
+     *
      * @param UploadedFile $uploadedFile
-     * @param Attachment $attachment
-     * @return Attachment
+     * @param Attachment   $attachment
+     *
      * @throws FileUploadException
+     *
+     * @return Attachment
      */
     public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment)
     {
@@ -83,53 +102,48 @@ class AttachmentService extends UploadService
         $attachment->external = false;
         $attachment->extension = $uploadedFile->getClientOriginalExtension();
         $attachment->save();
+
         return $attachment;
     }
 
     /**
      * Save a new File attachment from a given link and name.
-     * @param string $name
-     * @param string $link
-     * @param int $page_id
-     * @return Attachment
      */
-    public function saveNewFromLink($name, $link, $page_id)
+    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,
         ]);
     }
 
     /**
-     * Updates the file ordering for a listing of attached files.
-     * @param array $attachmentList
-     * @param $pageId
+     * Updates the ordering for a listing of attached files.
      */
-    public function updateFileOrderWithinPage($attachmentList, $pageId)
+    public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
     {
-        foreach ($attachmentList as $index => $attachment) {
-            Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
+        foreach ($attachmentOrder as $index => $attachmentId) {
+            Attachment::query()->where('uploaded_to', '=', $pageId)
+                ->where('id', '=', $attachmentId)
+                ->update(['order' => $index]);
         }
     }
 
-
     /**
      * Update the details of a file.
-     * @param Attachment $attachment
-     * @param $requestData
-     * @return Attachment
      */
-    public function updateFile(Attachment $attachment, $requestData)
+    public function updateFile(Attachment $attachment, array $requestData): Attachment
     {
         $attachment->name = $requestData['name'];
+
         if (isset($requestData['link']) && trim($requestData['link']) !== '') {
             $attachment->path = $requestData['link'];
             if (!$attachment->external) {
@@ -137,22 +151,27 @@ class AttachmentService extends UploadService
                 $attachment->external = true;
             }
         }
+
         $attachment->save();
+
         return $attachment;
     }
 
     /**
      * Delete a File from the database and storage.
+     *
      * @param Attachment $attachment
+     *
      * @throws Exception
      */
     public function deleteFile(Attachment $attachment)
     {
         if ($attachment->external) {
             $attachment->delete();
+
             return;
         }
-        
+
         $this->deleteFileInStorage($attachment);
         $attachment->delete();
     }
@@ -160,6 +179,7 @@ class AttachmentService extends UploadService
     /**
      * Delete a file from the filesystem it sits on.
      * Cleans any empty leftover folders.
+     *
      * @param Attachment $attachment
      */
     protected function deleteFileInStorage(Attachment $attachment)
@@ -174,17 +194,20 @@ class AttachmentService extends UploadService
     }
 
     /**
-     * Store a file in storage with the given filename
+     * Store a file in storage with the given filename.
+     *
      * @param UploadedFile $uploadedFile
-     * @return string
+     *
      * @throws FileUploadException
+     *
+     * @return string
      */
     protected function putFileInStorage(UploadedFile $uploadedFile)
     {
         $attachmentData = file_get_contents($uploadedFile->getRealPath());
 
         $storage = $this->getStorage();
-        $basePath = 'uploads/files/' . Date('Y-m-M') . '/';
+        $basePath = 'uploads/files/' . date('Y-m-M') . '/';
 
         $uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
         while ($storage->exists($basePath . $uploadFileName)) {
@@ -192,9 +215,12 @@ class AttachmentService extends UploadService
         }
 
         $attachmentPath = $basePath . $uploadFileName;
+
         try {
             $storage->put($attachmentPath, $attachmentData);
         } catch (Exception $e) {
+            Log::error('Error when attempting file upload:' . $e->getMessage());
+
             throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
         }
 
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 c76979d7cab0c5bee668b3e6a993d781842aa77c..4e0abc85b9a4d49128d4841cfc35548dff4dcb3b 100644 (file)
@@ -1,34 +1,43 @@
-<?php namespace BookStack\Uploads;
+<?php
 
-use BookStack\Entities\Page;
-use BookStack\Ownable;
-use Images;
+namespace BookStack\Uploads;
 
-class Image extends Ownable
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+use BookStack\Traits\HasCreatorAndUpdater;
+
+/**
+ * @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 HasCreatorAndUpdater;
 
     protected $fillable = ['name'];
     protected $hidden = [];
 
     /**
      * 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 b7a21809f18ab7347e44945b8f0933815bf57b7e..11507856140a0a7c330991cb8c488a1167257170 100644 (file)
@@ -1,7 +1,9 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\ImageUploadException;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
@@ -9,7 +11,6 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class ImageRepo
 {
-
     protected $image;
     protected $imageService;
     protected $restrictionService;
@@ -30,7 +31,6 @@ class ImageRepo
         $this->page = $page;
     }
 
-
     /**
      * Get an image with the given id.
      */
@@ -54,8 +54,8 @@ class ImageRepo
         });
 
         return [
-            'images'  => $returnImages,
-            'has_more' => $hasMore
+            'images'   => $returnImages,
+            'has_more' => $hasMore,
         ];
     }
 
@@ -70,8 +70,7 @@ class ImageRepo
         int $uploadedTo = null,
         string $search = null,
         callable $whereClause = null
-    ): array
-    {
+    ): array {
         $imageQuery = $this->image->newQuery()->where('type', '=', strtolower($type));
 
         if ($uploadedTo !== null) {
@@ -83,7 +82,7 @@ class ImageRepo
         }
 
         // Filter by page access
-        $imageQuery = $this->restrictionService->filterRelatedEntity('page', $imageQuery, 'images', 'uploaded_to');
+        $imageQuery = $this->restrictionService->filterRelatedEntity(Page::class, $imageQuery, 'images', 'uploaded_to');
 
         if ($whereClause !== null) {
             $imageQuery = $imageQuery->where($whereClause);
@@ -102,8 +101,7 @@ class ImageRepo
         int $pageSize = 24,
         int $uploadedTo = null,
         string $search = null
-    ): array
-    {
+    ): array {
         $contextPage = $this->page->findOrFail($uploadedTo);
         $parentFilter = null;
 
@@ -112,7 +110,7 @@ class ImageRepo
                 if ($filterType === 'page') {
                     $query->where('uploaded_to', '=', $contextPage->id);
                 } elseif ($filterType === 'book') {
-                    $validPageIds = $contextPage->book->pages()->get(['id'])->pluck('id')->toArray();
+                    $validPageIds = $contextPage->book->pages()->visible()->get(['id'])->pluck('id')->toArray();
                     $query->whereIn('uploaded_to', $validPageIds);
                 }
             };
@@ -123,28 +121,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 = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
+        $this->loadThumbs($image);
+
         return $image;
     }
 
     /**
      * Save a drawing the the database.
+     *
      * @throws ImageUploadException
      */
     public function saveDrawing(string $base64Uri, int $uploadedTo): Image
     {
         $name = 'Drawing-' . strval(user()->id) . '-' . strval(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
      */
@@ -153,11 +168,13 @@ 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
@@ -165,11 +182,13 @@ class ImageRepo
         if ($image) {
             $this->imageService->destroy($image);
         }
+
         return true;
     }
 
     /**
      * Destroy all images of a certain type.
+     *
      * @throws Exception
      */
     public function destroyByType(string $imageType)
@@ -180,16 +199,16 @@ class ImageRepo
         }
     }
 
-
     /**
      * Load thumbnails onto an image object.
+     *
      * @throws Exception
      */
-    protected function loadThumbs(Image $image)
+    public function loadThumbs(Image $image)
     {
         $image->thumbs = [
             'gallery' => $this->getThumbnail($image, 150, 150, false),
-            'display' => $this->getThumbnail($image, 1680, null, true)
+            'display' => $this->getThumbnail($image, 1680, null, true),
         ];
     }
 
@@ -197,6 +216,7 @@ class ImageRepo
      * 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
@@ -219,4 +239,20 @@ class ImageRepo
             return null;
         }
     }
+
+    /**
+     * Get the user visible pages using the given image.
+     */
+    public function getPagesUsingImage(Image $image): array
+    {
+        $pages = Page::visible()
+            ->where('html', 'like', '%' . $image->url . '%')
+            ->get(['id', 'name', 'slug', 'book_id']);
+
+        foreach ($pages as $page) {
+            $page->url = $page->getUrl();
+        }
+
+        return $pages->all();
+    }
 }
index 756149fe7a1bacdc2f11a2f4ee3672ceb50c8676..2c38c24f4f7c5f6d31646722937516e42b4cb042 100644 (file)
@@ -1,50 +1,44 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
-use BookStack\Auth\User;
-use BookStack\Exceptions\HttpFetchException;
 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\FileNotFoundException;
+use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+use Illuminate\Contracts\Filesystem\Filesystem as Storage;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 use Intervention\Image\Exception\NotSupportedException;
 use Intervention\Image\ImageManager;
-use phpDocumentor\Reflection\Types\Integer;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
-class ImageService extends UploadService
+class ImageService
 {
-
     protected $imageTool;
     protected $cache;
     protected $storageUrl;
     protected $image;
-    protected $http;
+    protected $fileSystem;
 
     /**
      * ImageService constructor.
-     * @param Image $image
-     * @param ImageManager $imageTool
-     * @param FileSystem $fileSystem
-     * @param Cache $cache
-     * @param HttpFetcher $http
      */
-    public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache, HttpFetcher $http)
+    public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
     {
         $this->image = $image;
         $this->imageTool = $imageTool;
+        $this->fileSystem = $fileSystem;
         $this->cache = $cache;
-        $this->http = $http;
-        parent::__construct($fileSystem);
     }
 
     /**
      * Get the storage that will be used for storing images.
-     * @param string $type
-     * @return \Illuminate\Contracts\Filesystem\Filesystem
      */
-    protected function getStorage($type = '')
+    protected function getStorage(string $type = ''): FileSystemInstance
     {
         $storageType = config('filesystems.images');
 
@@ -58,14 +52,10 @@ class ImageService extends UploadService
 
     /**
      * Saves a new image from an upload.
-     * @param UploadedFile $uploadedFile
-     * @param string $type
-     * @param int $uploadedTo
-     * @param int|null $resizeWidth
-     * @param int|null $resizeHeight
-     * @param bool $keepRatio
-     * @return mixed
+     *
      * @throws ImageUploadException
+     *
+     * @return mixed
      */
     public function saveNewFromUpload(
         UploadedFile $uploadedFile,
@@ -87,81 +77,56 @@ class ImageService extends UploadService
 
     /**
      * Save a new image from a uri-encoded base64 string of data.
-     * @param string $base64Uri
-     * @param string $name
-     * @param string $type
-     * @param int $uploadedTo
-     * @return Image
+     *
      * @throws ImageUploadException
      */
-    public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, $uploadedTo = 0)
+    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);
-    }
 
-    /**
-     * Gets an image from url and saves it to the database.
-     * @param             $url
-     * @param string      $type
-     * @param bool|string $imageName
-     * @return mixed
-     * @throws \Exception
-     */
-    private function saveNewFromUrl($url, $type, $imageName = false)
-    {
-        $imageName = $imageName ? $imageName : basename($url);
-        try {
-            $imageData = $this->http->fetch($url);
-        } catch (HttpFetchException $exception) {
-            throw new \Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
-        }
-        return $this->saveNew($imageName, $imageData, $type);
+        return $this->saveNew($name, $data, $type, $uploadedTo);
     }
 
     /**
-     * Saves a new image
-     * @param string $imageName
-     * @param string $imageData
-     * @param string $type
-     * @param int $uploadedTo
-     * @return Image
+     * Save a new image into storage.
+     *
      * @throws ImageUploadException
      */
-    private function saveNew($imageName, $imageData, $type, $uploadedTo = 0)
+    public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
         $storage = $this->getStorage($type);
         $secureUploads = setting('app-secure-images');
-        $imageName = str_replace(' ', '-', $imageName);
+        $fileName = $this->cleanImageFileName($imageName);
 
-        $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
+        $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
 
-        while ($storage->exists($imagePath . $imageName)) {
-            $imageName = Str::random(3) . $imageName;
+        while ($storage->exists($imagePath . $fileName)) {
+            $fileName = Str::random(3) . $fileName;
         }
 
-        $fullPath = $imagePath . $imageName;
+        $fullPath = $imagePath . $fileName;
         if ($secureUploads) {
-            $fullPath = $imagePath . Str::random(16) . '-' . $imageName;
+            $fullPath = $imagePath . Str::random(16) . '-' . $fileName;
         }
 
         try {
-            $storage->put($fullPath, $imageData);
-            $storage->setVisibility($fullPath, 'public');
+            $this->saveImageDataInPublicSpace($storage, $fullPath, $imageData);
         } catch (Exception $e) {
+            \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) {
@@ -172,16 +137,51 @@ class ImageService extends UploadService
 
         $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.
+     */
+    protected function cleanImageFileName(string $name): string
+    {
+        $name = str_replace(' ', '-', $name);
+        $nameParts = explode('.', $name);
+        $extension = array_pop($nameParts);
+        $name = implode('-', $nameParts);
+        $name = Str::slug($name);
+
+        if (strlen($name) === 0) {
+            $name = Str::random(10);
+        }
+
+        return $name . '.' . $extension;
+    }
 
     /**
      * Checks if the image is a gif. Returns true if it is, else false.
-     * @param Image $image
-     * @return boolean
      */
-    protected function isGif(Image $image)
+    protected function isGif(Image $image): bool
     {
         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
     }
@@ -190,13 +190,16 @@ class ImageService extends UploadService
      * 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
+     * @param int   $width
+     * @param int   $height
+     * @param bool  $keepRatio
+     *
      * @throws Exception
      * @throws ImageUploadException
+     *
+     * @return string
      */
     public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
     {
@@ -219,8 +222,7 @@ class ImageService extends UploadService
 
         $thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
 
-        $storage->put($thumbFilePath, $thumbData);
-        $storage->setVisibility($thumbFilePath, 'public');
+        $this->saveImageDataInPublicSpace($storage, $thumbFilePath, $thumbData);
         $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
 
         return $this->getPublicUrl($thumbFilePath);
@@ -228,21 +230,25 @@ class ImageService extends UploadService
 
     /**
      * Resize image data.
+     *
      * @param string $imageData
-     * @param int $width
-     * @param int $height
-     * @param bool $keepRatio
-     * @return string
+     * @param int    $width
+     * @param int    $height
+     * @param bool   $keepRatio
+     *
      * @throws ImageUploadException
+     *
+     * @return string
      */
     protected function resizeImage(string $imageData, $width = 220, $height = null, bool $keepRatio = true)
     {
         try {
             $thumb = $this->imageTool->make($imageData);
         } catch (Exception $e) {
-            if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
+            if ($e instanceof ErrorException || $e instanceof NotSupportedException) {
                 throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
             }
+
             throw $e;
         }
 
@@ -255,7 +261,7 @@ class ImageService extends UploadService
             $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.
@@ -268,20 +274,20 @@ class ImageService extends UploadService
 
     /**
      * Get the raw data content from an image.
-     * @param Image $image
-     * @return string
-     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+     *
+     * @throws FileNotFoundException
      */
-    public function getImageData(Image $image)
+    public function getImageData(Image $image): string
     {
         $imagePath = $image->path;
         $storage = $this->getStorage();
+
         return $storage->get($imagePath);
     }
 
     /**
      * Destroy an image along with its revisions, thumbnails and remaining folders.
-     * @param Image $image
+     *
      * @throws Exception
      */
     public function destroy(Image $image)
@@ -292,11 +298,9 @@ class ImageService extends UploadService
 
     /**
      * Destroys an image at the given path.
-     * Searches for image thumbnails in addition to main provided path..
-     * @param string $path
-     * @return bool
+     * Searches for image thumbnails in addition to main provided path.
      */
-    protected function destroyImagesFromPath(string $path)
+    protected function destroyImagesFromPath(string $path): bool
     {
         $storage = $this->getStorage();
 
@@ -306,15 +310,14 @@ class ImageService extends UploadService
 
         // Delete image files
         $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
-            $expectedIndex = strlen($imagePath) - strlen($imageFileName);
-            return strpos($imagePath, $imageFileName) === $expectedIndex;
+            return basename($imagePath) === $imageFileName;
         });
         $storage->delete($imagesToDelete->all());
 
         // Cleanup of empty folders
         $foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
         foreach ($foldersInvolved as $directory) {
-            if ($this->isFolderEmpty($directory)) {
+            if ($this->isFolderEmpty($storage, $directory)) {
                 $storage->deleteDirectory($directory);
             }
         }
@@ -323,57 +326,14 @@ class ImageService extends UploadService
     }
 
     /**
-     * Save an avatar image from an external service.
-     * @param \BookStack\Auth\User $user
-     * @param int $size
-     * @return Image
-     * @throws Exception
-     */
-    public function saveUserAvatar(User $user, $size = 500)
-    {
-        $avatarUrl = $this->getAvatarUrl();
-        $email = strtolower(trim($user->email));
-
-        $replacements = [
-            '${hash}' => md5($email),
-            '${size}' => $size,
-            '${email}' => urlencode($email),
-        ];
-
-        $userAvatarUrl = strtr($avatarUrl, $replacements);
-        $imageName = str_replace(' ', '-', $user->name . '-avatar.png');
-        $image = $this->saveNewFromUrl($userAvatarUrl, 'user', $imageName);
-        $image->created_by = $user->id;
-        $image->updated_by = $user->id;
-        $image->uploaded_to = $user->id;
-        $image->save();
-
-        return $image;
-    }
-
-    /**
-     * Check if fetching external avatars is enabled.
-     * @return bool
-     */
-    public function avatarFetchEnabled()
-    {
-        $fetchUrl = $this->getAvatarUrl();
-        return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
-    }
-
-    /**
-     * Get the URL to fetch avatars from.
-     * @return string|mixed
+     * Check whether or not a folder is empty.
      */
-    protected function getAvatarUrl()
+    protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
     {
-        $url = trim(config('services.avatar_url'));
+        $files = $storage->files($path);
+        $folders = $storage->directories($path);
 
-        if (empty($url) && !config('services.disable_services')) {
-            $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
-        }
-
-        return $url;
+        return count($files) === 0 && count($folders) === 0;
     }
 
     /**
@@ -382,26 +342,23 @@ class ImageService extends UploadService
      * Could be much improved to be more specific but kept it generic for now to be safe.
      *
      * Returns the path of the images that would be/have been deleted.
-     * @param bool $checkRevisions
-     * @param bool $dryRun
-     * @param array $types
-     * @return array
      */
-    public function deleteUnusedImages($checkRevisions = true, $dryRun = true, $types = ['gallery', 'drawio'])
+    public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
     {
-        $types = array_intersect($types, ['gallery', 'drawio']);
+        $types = ['gallery', 'drawio'];
         $deletedPaths = [];
 
         $this->image->newQuery()->whereIn('type', $types)
-            ->chunk(1000, function ($images) use ($types, $checkRevisions, &$deletedPaths, $dryRun) {
+            ->chunk(1000, function ($images) use ($checkRevisions, &$deletedPaths, $dryRun) {
                 foreach ($images as $image) {
                     $searchQuery = '%' . basename($image->path) . '%';
                     $inPage = DB::table('pages')
-                         ->where('html', 'like', $searchQuery)->count() > 0;
+                            ->where('html', 'like', $searchQuery)->count() > 0;
+
                     $inRevision = false;
                     if ($checkRevisions) {
-                        $inRevision =  DB::table('page_revisions')
-                             ->where('html', 'like', $searchQuery)->count() > 0;
+                        $inRevision = DB::table('page_revisions')
+                                ->where('html', 'like', $searchQuery)->count() > 0;
                     }
 
                     if (!$inPage && !$inRevision) {
@@ -412,43 +369,32 @@ class ImageService extends UploadService
                     }
                 }
             });
+
         return $deletedPaths;
     }
 
     /**
      * Convert a image URI to a Base64 encoded string.
-     * Attempts to find locally via set storage method first.
-     * @param string $uri
-     * @return null|string
-     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+     * 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)
+    public function imageUriToBase64(string $uri): ?string
     {
-        $isLocal = strpos(trim($uri), 'http') !== 0;
-
-        // Attempt to find local files even if url not absolute
-        $base = url('/');
-        if (!$isLocal && strpos($uri, $base) === 0) {
-            $isLocal = true;
-            $uri = str_replace($base, '', $uri);
+        $storagePath = $this->imageUrlToStoragePath($uri);
+        if (empty($uri) || is_null($storagePath)) {
+            return null;
         }
 
+        $storage = $this->getStorage();
         $imageData = null;
-
-        if ($isLocal) {
-            $uri = trim($uri, '/');
-            $storage = $this->getStorage();
-            if ($storage->exists($uri)) {
-                $imageData = $storage->get($uri);
-            }
-        } else {
-            try {
-                $imageData = $this->http->fetch($uri);
-            } catch (\Exception $e) {
-            }
+        if ($storage->exists($storagePath)) {
+            $imageData = $storage->get($storagePath);
         }
 
-        if ($imageData === null) {
+        if (is_null($imageData)) {
             return null;
         }
 
@@ -460,12 +406,46 @@ class ImageService extends UploadService
         return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
     }
 
+    /**
+     * Get a storage path for the given image URL.
+     * Ensures the path will start with "uploads/images".
+     * Returns null if the url cannot be resolved to a local URL.
+     */
+    private function imageUrlToStoragePath(string $url): ?string
+    {
+        $url = ltrim(trim($url), '/');
+
+        // Handle potential relative paths
+        $isRelative = strpos($url, 'http') !== 0;
+        if ($isRelative) {
+            if (strpos(strtolower($url), 'uploads/images') === 0) {
+                return trim($url, '/');
+            }
+
+            return null;
+        }
+
+        // Handle local images based on paths on the same domain
+        $potentialHostPaths = [
+            url('uploads/images/'),
+            $this->getPublicUrl('/uploads/images/'),
+        ];
+
+        foreach ($potentialHostPaths as $potentialBasePath) {
+            $potentialBasePath = strtolower($potentialBasePath);
+            if (strpos(strtolower($url), $potentialBasePath) === 0) {
+                return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
+            }
+        }
+
+        return null;
+    }
+
     /**
      * Gets a public facing url for an image by checking relevant environment variables.
-     * @param string $filePath
-     * @return string
+     * If s3-style store is in use it will default to guessing a public bucket URL.
      */
-    private function getPublicUrl($filePath)
+    private function getPublicUrl(string $filePath): string
     {
         if ($this->storageUrl === null) {
             $storageUrl = config('filesystems.url');
@@ -485,6 +465,7 @@ class ImageService extends UploadService
         }
 
         $basePath = ($this->storageUrl == false) ? url('/') : $this->storageUrl;
+
         return rtrim($basePath, '/') . $filePath;
     }
 }
diff --git a/app/Uploads/UploadService.php b/app/Uploads/UploadService.php
deleted file mode 100644 (file)
index 292e61e..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php namespace BookStack\Uploads;
-
-use Illuminate\Contracts\Filesystem\Factory as FileSystem;
-use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
-
-abstract class UploadService
-{
-
-    /**
-     * @var FileSystem
-     */
-    protected $fileSystem;
-
-
-    /**
-     * FileService constructor.
-     * @param $fileSystem
-     */
-    public function __construct(FileSystem $fileSystem)
-    {
-        $this->fileSystem = $fileSystem;
-    }
-
-    /**
-     * Get the storage that will be used for storing images.
-     * @return FileSystemInstance
-     */
-    protected function getStorage()
-    {
-        $storageType = config('filesystems.default');
-        return $this->fileSystem->disk($storageType);
-    }
-
-    /**
-     * Check whether or not a folder is empty.
-     * @param $path
-     * @return bool
-     */
-    protected function isFolderEmpty($path)
-    {
-        $files = $this->getStorage()->files($path);
-        $folders = $this->getStorage()->directories($path);
-        return (count($files) === 0 && count($folders) === 0);
-    }
-}
diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php
new file mode 100644 (file)
index 0000000..f5b085a
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use BookStack\Auth\User;
+use BookStack\Exceptions\HttpFetchException;
+use Exception;
+use Illuminate\Support\Facades\Log;
+
+class UserAvatars
+{
+    protected $imageService;
+    protected $http;
+
+    public function __construct(ImageService $imageService, HttpFetcher $http)
+    {
+        $this->imageService = $imageService;
+        $this->http = $http;
+    }
+
+    /**
+     * Fetch and assign an avatar image to the given user.
+     */
+    public function fetchAndAssignToUser(User $user): void
+    {
+        if (!$this->avatarFetchEnabled()) {
+            return;
+        }
+
+        try {
+            $this->destroyAllForUser($user);
+            $avatar = $this->saveAvatarImage($user);
+            $user->avatar()->associate($avatar);
+            $user->save();
+        } catch (Exception $e) {
+            Log::error('Failed to save user avatar image');
+        }
+    }
+
+    /**
+     * 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
+    {
+        $avatarUrl = $this->getAvatarUrl();
+        $email = strtolower(trim($user->email));
+
+        $replacements = [
+            '${hash}'  => md5($email),
+            '${size}'  => $size,
+            '${email}' => urlencode($email),
+        ];
+
+        $userAvatarUrl = strtr($avatarUrl, $replacements);
+        $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;
+        $image->save();
+
+        return $image;
+    }
+
+    /**
+     * Gets an image from url and returns it as a string of image data.
+     *
+     * @throws Exception
+     */
+    protected function getAvatarImageData(string $url): string
+    {
+        try {
+            $imageData = $this->http->fetch($url);
+        } catch (HttpFetchException $exception) {
+            throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
+        }
+
+        return $imageData;
+    }
+
+    /**
+     * Check if fetching external avatars is enabled.
+     */
+    protected function avatarFetchEnabled(): bool
+    {
+        $fetchUrl = $this->getAvatarUrl();
+
+        return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
+    }
+
+    /**
+     * Get the URL to fetch avatars from.
+     */
+    protected function getAvatarUrl(): string
+    {
+        $url = trim(config('services.avatar_url'));
+
+        if (empty($url) && !config('services.disable_services')) {
+            $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
+        }
+
+        return $url;
+    }
+}
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));
+    }
+}
diff --git a/app/Util/HtmlContentFilter.php b/app/Util/HtmlContentFilter.php
new file mode 100644 (file)
index 0000000..1943aa7
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+
+namespace BookStack\Util;
+
+use DOMAttr;
+use DOMDocument;
+use DOMNodeList;
+use DOMXPath;
+
+class HtmlContentFilter
+{
+    /**
+     * Remove all the script elements from the given HTML.
+     */
+    public static function removeScripts(string $html): string
+    {
+        if (empty($html)) {
+            return $html;
+        }
+
+        $html = '<body>' . $html . '</body>';
+        libxml_use_internal_errors(true);
+        $doc = new DOMDocument();
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+        $xPath = new DOMXPath($doc);
+
+        // Remove standard script tags
+        $scriptElems = $xPath->query('//script');
+        static::removeNodes($scriptElems);
+
+        // Remove clickable links to JavaScript URI
+        $badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
+        static::removeNodes($badLinks);
+
+        // Remove forms with calls to JavaScript URI
+        $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[' . static::xpathContains('@content', 'url') . ']');
+        static::removeNodes($metaTags);
+
+        // Remove data or JavaScript iFrames
+        $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\')]');
+        static::removeAttributes($onAttributes);
+
+        $html = '';
+        $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
+        foreach ($topElems as $child) {
+            $html .= $doc->saveHTML($child);
+        }
+
+        return $html;
+    }
+
+    /**
+     * 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.
+     */
+    protected static function removeNodes(DOMNodeList $nodes): void
+    {
+        foreach ($nodes as $node) {
+            $node->parentNode->removeChild($node);
+        }
+    }
+
+    /**
+     * Remove all the given attribute nodes.
+     */
+    protected static function removeAttributes(DOMNodeList $attrs): void
+    {
+        /** @var DOMAttr $attr */
+        foreach ($attrs as $attr) {
+            $attrName = $attr->nodeName;
+            $attr->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);
+        }
+    }
+}
index 65da1853b54e54bb43a8cb8fe5d9959a3a877578..9edc22c403d9b1a7859baaec7983a8ad14e898a4 100644 (file)
@@ -2,14 +2,12 @@
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\User;
-use BookStack\Ownable;
+use BookStack\Model;
 use BookStack\Settings\SettingService;
 
 /**
  * Get the path to a versioned file.
  *
- * @param  string $file
- * @return string
  * @throws Exception
  */
 function versioned_asset(string $file = ''): string
@@ -27,13 +25,13 @@ function versioned_asset(string $file = ''): string
     }
 
     $path = $file . '?version=' . urlencode($version) . $additional;
+
     return url($path);
 }
 
 /**
  * Helper method to get the current User.
  * Defaults to public 'Guest' user if not logged in.
- * @return User
  */
 function user(): User
 {
@@ -57,11 +55,10 @@ function hasAppAccess(): bool
 }
 
 /**
- * Check if the current user has a permission.
- * If an ownable element is passed in the jointPermissions are checked against
- * that particular item.
+ * Check if the current user has a permission. If an ownable element
+ * is passed in the jointPermissions are checked against that particular item.
  */
-function userCan(string $permission, Ownable $ownable = null): bool
+function userCan(string $permission, Model $ownable = null): bool
 {
     if ($ownable === null) {
         return user() && user()->can($permission);
@@ -69,50 +66,51 @@ function userCan(string $permission, Ownable $ownable = null): bool
 
     // Check permission on ownable item
     $permissionService = app(PermissionService::class);
+
     return $permissionService->checkOwnableUserAccess($ownable, $permission);
 }
 
 /**
  * Check if the current user has the given permission
  * on any item in the system.
- * @param string $permission
- * @param string|null $entityClass
- * @return bool
  */
 function userCanOnAny(string $permission, string $entityClass = null): bool
 {
     $permissionService = app(PermissionService::class);
+
     return $permissionService->checkUserHasPermissionOnAnything($permission, $entityClass);
 }
 
 /**
  * Helper to access system settings.
- * @param string $key
- * @param $default
- * @return bool|string|SettingService
+ *
+ * @return mixed|SettingService
  */
-function setting(string $key = null, $default = false)
+function setting(string $key = null, $default = null)
 {
     $settingService = resolve(SettingService::class);
+
     if (is_null($key)) {
         return $settingService;
     }
+
     return $settingService->get($key, $default);
 }
 
 /**
  * Get a path to a theme resource.
- * @param string $path
- * @return string
+ * 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));
 }
 
 /**
@@ -121,9 +119,6 @@ function theme_path(string $path = ''): string
  * to the 'resources/assets/icons' folder.
  *
  * Returns an empty string if icon file not found.
- * @param $name
- * @param array $attrs
- * @return mixed
  */
 function icon(string $name, array $attrs = []): string
 {
@@ -134,18 +129,20 @@ 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');
     $themeIconPath = theme_path('icons/' . $name . '.svg');
+
     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);
 }
 
@@ -153,10 +150,6 @@ function icon(string $name, array $attrs = []): string
  * Generate a url with multiple parameters for sorting purposes.
  * Works out the logic to set the correct sorting direction
  * Discards empty parameters and allows overriding.
- * @param string $path
- * @param array $data
- * @param array $overrideData
- * @return string
  */
 function sortUrl(string $path, array $data, array $overrideData = []): string
 {
@@ -166,7 +159,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string
     // Change sorting direction is already sorted on current attribute
     if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
         $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
-    } else {
+    } elseif (isset($overrideData['sort'])) {
         $queryData['order'] = 'asc';
     }
 
diff --git a/artisan b/artisan
index dad16dcdefdee1989b99de8cddffffe06d10a381..d5c6aaf98542479db38a44dce76de952dbc34de6 100755 (executable)
--- a/artisan
+++ b/artisan
@@ -5,15 +5,17 @@ define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader
+| for our application. We just need to utilize it! We'll require it
+| into the script here so that we do not have to worry about the
+| loading of any our classes "manually". Feels great to relax.
 |
 */
 
-require __DIR__.'/bootstrap/init.php';
+require __DIR__.'/vendor/autoload.php';
 
 $app = require_once __DIR__.'/bootstrap/app.php';
 
diff --git a/bootstrap/init.php b/bootstrap/init.php
deleted file mode 100644 (file)
index 7d9e43f..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-/*
-|--------------------------------------------------------------------------
-| Load Our Own Helpers
-|--------------------------------------------------------------------------
-|
-| This custom function loads any helpers, before the Laravel Framework
-| is built so we can override any helpers as we please.
-|
-*/
-require __DIR__.'/../app/helpers.php';
-
-/*
-|--------------------------------------------------------------------------
-| Register The Composer Auto Loader
-|--------------------------------------------------------------------------
-|
-| Composer provides a convenient, automatically generated class loader
-| for our application. We just need to utilize it! We'll require it
-| into the script here so that we do not have to worry about the
-| loading of any our classes "manually". Feels great to relax.
-|
-*/
-require __DIR__.'/../vendor/autoload.php';
\ No newline at end of file
index 7b1a3d5928973b53dd7f675648972f77b3fc9f2c..288f559913efdb55b1d287f132ef0821978f8575 100644 (file)
@@ -5,45 +5,46 @@
     "license": "MIT",
     "type": "project",
     "require": {
-        "php": "^7.2",
+        "php": "^7.3|^8.0",
         "ext-curl": "*",
         "ext-dom": "*",
+        "ext-fileinfo": "*",
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
-        "ext-tidy": "*",
         "ext-xml": "*",
-        "barryvdh/laravel-dompdf": "^0.8.6",
-        "barryvdh/laravel-snappy": "^0.4.7",
-        "doctrine/dbal": "^2.9",
-        "facade/ignition": "^1.4",
-        "fideloper/proxy": "^4.0",
-        "gathercontent/htmldiff": "^0.2.1",
-        "intervention/image": "^2.5",
-        "laravel/framework": "^6.18",
-        "laravel/socialite": "^4.3.2",
-        "league/commonmark": "^1.4",
-        "league/flysystem-aws-s3-v3": "^1.0",
-        "nunomaduro/collision": "^3.0",
-        "onelogin/php-saml": "^3.3",
-        "predis/predis": "^1.1",
-        "socialiteproviders/discord": "^2.0",
-        "socialiteproviders/gitlab": "^3.0",
-        "socialiteproviders/microsoft-azure": "^3.0",
-        "socialiteproviders/okta": "^1.0",
-        "socialiteproviders/slack": "^3.0",
-        "socialiteproviders/twitch": "^5.0",
+        "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.33",
+        "laravel/socialite": "^5.1",
+        "league/commonmark": "^1.5",
+        "league/flysystem-aws-s3-v3": "^1.0.29",
+        "league/html-to-markdown": "^5.0.0",
+        "nunomaduro/collision": "^3.1",
+        "onelogin/php-saml": "^4.0",
+        "pragmarx/google2fa": "^8.0",
+        "predis/predis": "^1.1.6",
+        "socialiteproviders/discord": "^4.1",
+        "socialiteproviders/gitlab": "^4.1",
+        "socialiteproviders/microsoft-azure": "^4.1",
+        "socialiteproviders/okta": "^4.1",
+        "socialiteproviders/slack": "^4.1",
+        "socialiteproviders/twitch": "^5.3",
+        "ssddanbrown/htmldiff": "^v1.0.1",
         "steverhoades/oauth2-openid-connect-client": "^0.3.0"
     },
     "require-dev": {
-        "barryvdh/laravel-debugbar": "^3.2.8",
-        "barryvdh/laravel-ide-helper": "^2.6.4",
-        "fzaninotto/faker": "^1.4",
-        "laravel/browser-kit-testing": "^5.1",
-        "mockery/mockery": "^1.0",
-        "phpunit/phpunit": "^8.0",
-        "squizlabs/php_codesniffer": "^3.4",
-        "wnx/laravel-stats": "^2.0"
+        "barryvdh/laravel-debugbar": "^3.5.1",
+        "barryvdh/laravel-ide-helper": "^2.8.2",
+        "fakerphp/faker": "^1.13.0",
+        "mockery/mockery": "^1.3.3",
+        "phpunit/phpunit": "^9.5.3",
+        "symfony/dom-crawler": "^5.3"
     },
     "autoload": {
         "classmap": [
         ],
         "psr-4": {
             "BookStack\\": "app/"
-        }
+        },
+               "files": [
+                       "app/helpers.php"
+               ]
     },
     "autoload-dev": {
         "psr-4": {
         "post-create-project-cmd": [
             "@php artisan key:generate --ansi"
         ],
-        "pre-update-cmd": [
-            "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\"",
-            "@php -r \"!file_exists('bootstrap/cache/compiled.php') || @unlink('bootstrap/cache/compiled.php');\""
-        ],
         "pre-install-cmd": [
-            "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\"",
-            "@php -r \"!file_exists('bootstrap/cache/compiled.php') || @unlink('bootstrap/cache/compiled.php');\""
+            "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\""
         ],
         "post-install-cmd": [
             "@php artisan cache:clear",
@@ -92,7 +91,7 @@
         "preferred-install": "dist",
         "sort-packages": true,
         "platform": {
-            "php": "7.2.0"
+            "php": "7.3.0"
         }
     },
     "extra": {
index 0f5e29792e950d09396f9365a8f28cacc0bb5e40..a3cfe6e7e570d989d17d17e4e3aa47bceb0c22b6 100644 (file)
@@ -4,30 +4,81 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "f9d604c2456771f9b939f04492dde182",
+    "content-hash": "10825887b8f66d1d412b92bcc0ca864f",
     "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.138.7",
+            "version": "3.194.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "6b9f3fcea4dfa6092c628c790ca6d369a75453b7"
+                "reference": "67bdee05acef9e8ad60098090996690b49babd09"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/6b9f3fcea4dfa6092c628c790ca6d369a75453b7",
-                "reference": "6b9f3fcea4dfa6092c628c790ca6d369a75453b7",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67bdee05acef9e8ad60098090996690b49babd09",
+                "reference": "67bdee05acef9e8ad60098090996690b49babd09",
                 "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.0",
-                "guzzlehttp/psr7": "^1.4.1",
-                "mtdowling/jmespath.php": "^2.5",
+                "guzzlehttp/promises": "^1.4.0",
+                "guzzlehttp/psr7": "^1.7.0",
+                "mtdowling/jmespath.php": "^2.6",
                 "php": ">=5.5"
             },
             "require-dev": {
@@ -40,6 +91,7 @@
                 "ext-pcntl": "*",
                 "ext-sockets": "*",
                 "nette/neon": "^2.3",
+                "paragonie/random_compat": ">= 2",
                 "phpunit/phpunit": "^4.8.35|^5.4.3",
                 "psr/cache": "^1.0",
                 "psr/simple-cache": "^1.0",
                 "s3",
                 "sdk"
             ],
-            "time": "2020-05-22T18:11:09+00:00"
+            "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.194.1"
+            },
+            "time": "2021-09-17T18:15:42+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-06-18T13:26:35+00:00"
         },
         {
             "name": "barryvdh/laravel-dompdf",
-            "version": "v0.8.6",
+            "version": "v0.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/barryvdh/laravel-dompdf.git",
-                "reference": "d7108f78cf5254a2d8c224542967f133e5a6d4e8"
+                "reference": "5b99e1f94157d74e450f4c97e8444fcaffa2144b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/d7108f78cf5254a2d8c224542967f133e5a6d4e8",
-                "reference": "d7108f78cf5254a2d8c224542967f133e5a6d4e8",
+                "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/5b99e1f94157d74e450f4c97e8444fcaffa2144b",
+                "reference": "5b99e1f94157d74e450f4c97e8444fcaffa2144b",
                 "shasum": ""
             },
             "require": {
-                "dompdf/dompdf": "^0.8",
-                "illuminate/support": "^5.5|^6|^7",
-                "php": ">=7"
+                "dompdf/dompdf": "^1",
+                "illuminate/support": "^5.5|^6|^7|^8",
+                "php": "^7.1 || ^8.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "0.8-dev"
+                    "dev-master": "0.9-dev"
                 },
                 "laravel": {
                     "providers": [
                 "laravel",
                 "pdf"
             ],
-            "time": "2020-02-25T20:44:34+00:00"
+            "support": {
+                "issues": "https://github.com/barryvdh/laravel-dompdf/issues",
+                "source": "https://github.com/barryvdh/laravel-dompdf/tree/v0.9.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/barryvdh",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-12-27T12:05:53+00:00"
         },
         {
             "name": "barryvdh/laravel-snappy",
-            "version": "v0.4.7",
+            "version": "v0.4.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/barryvdh/laravel-snappy.git",
-                "reference": "c412d0c8f40b1326ba0fb16e94957fd1e68374f0"
+                "reference": "1903ab84171072b6bff8d98eb58d38b2c9aaf645"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-snappy/zipball/c412d0c8f40b1326ba0fb16e94957fd1e68374f0",
-                "reference": "c412d0c8f40b1326ba0fb16e94957fd1e68374f0",
+                "url": "https://api.github.com/repos/barryvdh/laravel-snappy/zipball/1903ab84171072b6bff8d98eb58d38b2c9aaf645",
+                "reference": "1903ab84171072b6bff8d98eb58d38b2c9aaf645",
                 "shasum": ""
             },
             "require": {
-                "illuminate/filesystem": "^5.5|^6|^7",
-                "illuminate/support": "^5.5|^6|^7",
+                "illuminate/filesystem": "^5.5|^6|^7|^8",
+                "illuminate/support": "^5.5|^6|^7|^8",
                 "knplabs/knp-snappy": "^1",
                 "php": ">=7"
             },
                 "wkhtmltoimage",
                 "wkhtmltopdf"
             ],
-            "time": "2020-02-25T20:52:15+00:00"
+            "support": {
+                "issues": "https://github.com/barryvdh/laravel-snappy/issues",
+                "source": "https://github.com/barryvdh/laravel-snappy/tree/master"
+            },
+            "time": "2020-09-07T12:33:10+00:00"
         },
         {
-            "name": "cogpowered/finediff",
-            "version": "0.3.1",
+            "name": "dasprid/enum",
+            "version": "1.0.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/cogpowered/FineDiff.git",
-                "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8"
+                "url": "https://github.com/DASPRiD/Enum.git",
+                "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/cogpowered/FineDiff/zipball/339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
-                "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
+                "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/5abf82f213618696dda8e3bf6f64dd042d8542b2",
+                "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2",
                 "shasum": ""
             },
-            "require": {
-                "php": ">=5.3.0"
-            },
             "require-dev": {
-                "mockery/mockery": "*",
-                "phpunit/phpunit": "*"
+                "phpunit/phpunit": "^7 | ^8 | ^9",
+                "squizlabs/php_codesniffer": "^3.4"
             },
             "type": "library",
             "autoload": {
-                "psr-0": {
-                    "cogpowered\\FineDiff": "src/"
+                "psr-4": {
+                    "DASPRiD\\Enum\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "MIT"
+                "BSD-2-Clause"
             ],
             "authors": [
                 {
-                    "name": "Rob Crowe",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Raymond Hill"
+                    "name": "Ben Scholzen 'DASPRiD'",
+                    "email": "[email protected]",
+                    "homepage": "https://dasprids.de/",
+                    "role": "Developer"
                 }
             ],
-            "description": "PHP implementation of a Fine granularity Diff engine",
-            "homepage": "https://github.com/cogpowered/FineDiff",
+            "description": "PHP 7.1 enum implementation",
             "keywords": [
-                "diff",
-                "finediff",
-                "opcode",
-                "string",
-                "text"
+                "enum",
+                "map"
             ],
-            "time": "2014-05-19T10:25:02+00:00"
+            "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.10.0",
+            "version": "2.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/cache.git",
-                "reference": "382e7f4db9a12dc6c19431743a2b096041bcdd62"
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/cache/zipball/382e7f4db9a12dc6c19431743a2b096041bcdd62",
-                "reference": "382e7f4db9a12dc6c19431743a2b096041bcdd62",
+                "url": "https://api.github.com/repos/doctrine/cache/zipball/331b4d5dbaeab3827976273e9356b3b453c300ce",
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce",
                 "shasum": ""
             },
             "require": {
-                "php": "~7.1"
+                "php": "~7.1 || ^8.0"
             },
             "conflict": {
                 "doctrine/common": ">2.2,<2.4"
             },
             "require-dev": {
                 "alcaeus/mongo-php-adapter": "^1.1",
-                "doctrine/coding-standard": "^6.0",
+                "cache/integration-tests": "dev-master",
+                "doctrine/coding-standard": "^8.0",
                 "mongodb/mongodb": "^1.1",
-                "phpunit/phpunit": "^7.0",
-                "predis/predis": "~1.0"
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "predis/predis": "~1.0",
+                "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"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.9.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache"
                 "redis",
                 "xcache"
             ],
-            "time": "2019-11-29T15:36:20+00:00"
+            "support": {
+                "issues": "https://github.com/doctrine/cache/issues",
+                "source": "https://github.com/doctrine/cache/tree/2.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-07-17T14:49:29+00:00"
         },
         {
             "name": "doctrine/dbal",
-            "version": "2.10.2",
+            "version": "2.13.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "aab745e7b6b2de3b47019da81e7225e14dcfdac8"
+                "reference": "0d7adf4cadfee6f70850e5b163e6cdd706417838"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/aab745e7b6b2de3b47019da81e7225e14dcfdac8",
-                "reference": "aab745e7b6b2de3b47019da81e7225e14dcfdac8",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/0d7adf4cadfee6f70850e5b163e6cdd706417838",
+                "reference": "0d7adf4cadfee6f70850e5b163e6cdd706417838",
                 "shasum": ""
             },
             "require": {
-                "doctrine/cache": "^1.0",
+                "doctrine/cache": "^1.0|^2.0",
+                "doctrine/deprecations": "^0.5.3",
                 "doctrine/event-manager": "^1.0",
                 "ext-pdo": "*",
-                "php": "^7.2"
+                "php": "^7.1 || ^8"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^6.0",
-                "jetbrains/phpstorm-stubs": "^2019.1",
-                "nikic/php-parser": "^4.4",
-                "phpstan/phpstan": "^0.12",
-                "phpunit/phpunit": "^8.4.1",
+                "doctrine/coding-standard": "9.0.0",
+                "jetbrains/phpstorm-stubs": "2021.1",
+                "phpstan/phpstan": "0.12.96",
+                "phpunit/phpunit": "^7.5.20|^8.5|9.5.5",
+                "psalm/plugin-phpunit": "0.16.1",
+                "squizlabs/php_codesniffer": "3.6.0",
+                "symfony/cache": "^4.4",
                 "symfony/console": "^2.0.5|^3.0|^4.0|^5.0",
-                "vimeo/psalm": "^3.11"
+                "vimeo/psalm": "4.10.0"
             },
             "suggest": {
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
                 "bin/doctrine-dbal"
             ],
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.10.x-dev",
-                    "dev-develop": "3.0.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\DBAL\\": "lib/Doctrine/DBAL"
                 "sqlserver",
                 "sqlsrv"
             ],
+            "support": {
+                "issues": "https://github.com/doctrine/dbal/issues",
+                "source": "https://github.com/doctrine/dbal/tree/2.13.3"
+            },
             "funding": [
                 {
                     "url": "https://www.doctrine-project.org/sponsorship.html",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-20T17:19:26+00:00"
+            "time": "2021-09-12T19:11:48+00:00"
+        },
+        {
+            "name": "doctrine/deprecations",
+            "version": "v0.5.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/deprecations.git",
+                "reference": "9504165960a1f83cc1480e2be1dd0a0478561314"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/deprecations/zipball/9504165960a1f83cc1480e2be1dd0a0478561314",
+                "reference": "9504165960a1f83cc1480e2be1dd0a0478561314",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1|^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^6.0|^7.0|^8.0",
+                "phpunit/phpunit": "^7.0|^8.0|^9.0",
+                "psr/log": "^1.0"
+            },
+            "suggest": {
+                "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+            "homepage": "https://www.doctrine-project.org/",
+            "support": {
+                "issues": "https://github.com/doctrine/deprecations/issues",
+                "source": "https://github.com/doctrine/deprecations/tree/v0.5.3"
+            },
+            "time": "2021-03-21T12:59:47+00:00"
         },
         {
             "name": "doctrine/event-manager",
-            "version": "1.1.0",
+            "version": "1.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/event-manager.git",
-                "reference": "629572819973f13486371cb611386eb17851e85c"
+                "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/event-manager/zipball/629572819973f13486371cb611386eb17851e85c",
-                "reference": "629572819973f13486371cb611386eb17851e85c",
+                "url": "https://api.github.com/repos/doctrine/event-manager/zipball/41370af6a30faa9dc0368c4a6814d596e81aba7f",
+                "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "conflict": {
                 "doctrine/common": "<2.9@dev"
                 "event system",
                 "events"
             ],
-            "time": "2019-11-10T09:48:07+00:00"
+            "support": {
+                "issues": "https://github.com/doctrine/event-manager/issues",
+                "source": "https://github.com/doctrine/event-manager/tree/1.1.x"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-05-29T18:28:51+00:00"
         },
         {
             "name": "doctrine/inflector",
-            "version": "2.0.1",
+            "version": "2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/inflector.git",
-                "reference": "18b995743e7ec8b15fd6efc594f0fa3de4bfe6d7"
+                "reference": "9cf661f4eb38f7c881cac67c75ea9b00bf97b210"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/inflector/zipball/18b995743e7ec8b15fd6efc594f0fa3de4bfe6d7",
-                "reference": "18b995743e7ec8b15fd6efc594f0fa3de4bfe6d7",
+                "url": "https://api.github.com/repos/doctrine/inflector/zipball/9cf661f4eb38f7c881cac67c75ea9b00bf97b210",
+                "reference": "9cf661f4eb38f7c881cac67c75ea9b00bf97b210",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2"
+                "php": "^7.2 || ^8.0"
             },
             "require-dev": {
                 "doctrine/coding-standard": "^7.0",
                 "uppercase",
                 "words"
             ],
+            "support": {
+                "issues": "https://github.com/doctrine/inflector/issues",
+                "source": "https://github.com/doctrine/inflector/tree/2.0.x"
+            },
             "funding": [
                 {
                     "url": "https://www.doctrine-project.org/sponsorship.html",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-11T11:25:59+00:00"
+            "time": "2020-05-29T15:13:26+00:00"
         },
         {
             "name": "doctrine/lexer",
-            "version": "1.2.0",
+            "version": "1.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/lexer.git",
-                "reference": "5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6"
+                "reference": "e864bbf5904cb8f5bb334f99209b48018522f042"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/lexer/zipball/5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6",
-                "reference": "5242d66dbeb21a30dd8a3e66bf7a73b66e05e1f6",
+                "url": "https://api.github.com/repos/doctrine/lexer/zipball/e864bbf5904cb8f5bb334f99209b48018522f042",
+                "reference": "e864bbf5904cb8f5bb334f99209b48018522f042",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2"
+                "php": "^7.2 || ^8.0"
             },
             "require-dev": {
                 "doctrine/coding-standard": "^6.0",
                 "parser",
                 "php"
             ],
-            "time": "2019-10-30T14:39:59+00:00"
+            "support": {
+                "issues": "https://github.com/doctrine/lexer/issues",
+                "source": "https://github.com/doctrine/lexer/tree/1.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-05-25T17:44:05+00:00"
         },
         {
             "name": "dompdf/dompdf",
-            "version": "v0.8.5",
+            "version": "v1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dompdf/dompdf.git",
-                "reference": "6782abfc090b132134cd6cea0ec6d76f0fce2c56"
+                "reference": "8768448244967a46d6e67b891d30878e0e15d25c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6782abfc090b132134cd6cea0ec6d76f0fce2c56",
-                "reference": "6782abfc090b132134cd6cea0ec6d76f0fce2c56",
+                "url": "https://api.github.com/repos/dompdf/dompdf/zipball/8768448244967a46d6e67b891d30878e0e15d25c",
+                "reference": "8768448244967a46d6e67b891d30878e0e15d25c",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-mbstring": "*",
-                "phenx/php-font-lib": "^0.5.1",
+                "phenx/php-font-lib": "^0.5.2",
                 "phenx/php-svg-lib": "^0.3.3",
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.5",
+                "mockery/mockery": "^1.3",
+                "phpunit/phpunit": "^7.5 || ^8 || ^9",
                 "squizlabs/php_codesniffer": "^3.5"
             },
             "suggest": {
                 "ext-gd": "Needed to process images",
                 "ext-gmagick": "Improves image processing performance",
-                "ext-imagick": "Improves image processing performance"
+                "ext-imagick": "Improves image processing performance",
+                "ext-zlib": "Needed for pdf stream compression"
             },
             "type": "library",
             "extra": {
             ],
             "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
             "homepage": "https://github.com/dompdf/dompdf",
-            "time": "2020-02-20T03:52:51+00:00"
+            "support": {
+                "issues": "https://github.com/dompdf/dompdf/issues",
+                "source": "https://github.com/dompdf/dompdf/tree/v1.0.2"
+            },
+            "time": "2021-01-08T14:18:52+00:00"
         },
         {
             "name": "dragonmantank/cron-expression",
-            "version": "v2.3.0",
+            "version": "v2.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dragonmantank/cron-expression.git",
-                "reference": "72b6fbf76adb3cf5bc0db68559b33d41219aba27"
+                "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/72b6fbf76adb3cf5bc0db68559b33d41219aba27",
-                "reference": "72b6fbf76adb3cf5bc0db68559b33d41219aba27",
+                "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/65b2d8ee1f10915efb3b55597da3404f096acba2",
+                "reference": "65b2d8ee1f10915efb3b55597da3404f096acba2",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.0"
+                "php": "^7.0|^8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^6.4|^7.0"
+                "phpunit/phpunit": "^6.4|^7.0|^8.0|^9.0"
             },
             "type": "library",
             "extra": {
                 "cron",
                 "schedule"
             ],
-            "time": "2019-03-31T00:38:28+00:00"
+            "support": {
+                "issues": "https://github.com/dragonmantank/cron-expression/issues",
+                "source": "https://github.com/dragonmantank/cron-expression/tree/v2.3.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/dragonmantank",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-13T00:52:37+00:00"
         },
         {
             "name": "egulias/email-validator",
-            "version": "2.1.17",
+            "version": "2.1.25",
             "source": {
                 "type": "git",
                 "url": "https://github.com/egulias/EmailValidator.git",
-                "reference": "ade6887fd9bd74177769645ab5c474824f8a418a"
+                "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ade6887fd9bd74177769645ab5c474824f8a418a",
-                "reference": "ade6887fd9bd74177769645ab5c474824f8a418a",
+                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/0dbf5d78455d4d6a41d186da50adc1122ec066f4",
+                "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4",
                 "shasum": ""
             },
             "require": {
             },
             "autoload": {
                 "psr-4": {
-                    "Egulias\\EmailValidator\\": "EmailValidator"
+                    "Egulias\\EmailValidator\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 "validation",
                 "validator"
             ],
-            "time": "2020-02-13T22:36:52+00:00"
+            "support": {
+                "issues": "https://github.com/egulias/EmailValidator/issues",
+                "source": "https://github.com/egulias/EmailValidator/tree/2.1.25"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/egulias",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-12-29T14:50:06+00:00"
         },
         {
             "name": "facade/flare-client-php",
-            "version": "1.3.2",
+            "version": "1.9.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/facade/flare-client-php.git",
-                "reference": "db1e03426e7f9472c9ecd1092aff00f56aa6c004"
+                "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/flare-client-php/zipball/db1e03426e7f9472c9ecd1092aff00f56aa6c004",
-                "reference": "db1e03426e7f9472c9ecd1092aff00f56aa6c004",
+                "url": "https://api.github.com/repos/facade/flare-client-php/zipball/b2adf1512755637d0cef4f7d1b54301325ac78ed",
+                "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed",
                 "shasum": ""
             },
             "require": {
                 "facade/ignition-contracts": "~1.0",
-                "illuminate/pipeline": "^5.5|^6.0|^7.0",
-                "php": "^7.1",
+                "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"
             },
             "require-dev": {
-                "larapack/dd": "^1.1",
+                "friendsofphp/php-cs-fixer": "^2.14",
                 "phpunit/phpunit": "^7.5.16",
                 "spatie/phpunit-snapshot-assertions": "^2.0"
             },
                 "flare",
                 "reporting"
             ],
-            "time": "2020-03-02T15:52:04+00:00"
+            "support": {
+                "issues": "https://github.com/facade/flare-client-php/issues",
+                "source": "https://github.com/facade/flare-client-php/tree/1.9.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/spatie",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-09-13T12:16:46+00:00"
         },
         {
             "name": "facade/ignition",
-            "version": "1.16.1",
+            "version": "1.18.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/facade/ignition.git",
-                "reference": "af05ac5ee8587395d7474ec0681c08776a2cb09d"
+                "reference": "fca0cbe5f900f94773d821b481c16d4ea3503491"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition/zipball/af05ac5ee8587395d7474ec0681c08776a2cb09d",
-                "reference": "af05ac5ee8587395d7474ec0681c08776a2cb09d",
+                "url": "https://api.github.com/repos/facade/ignition/zipball/fca0cbe5f900f94773d821b481c16d4ea3503491",
+                "reference": "fca0cbe5f900f94773d821b481c16d4ea3503491",
                 "shasum": ""
             },
             "require": {
                 "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",
+                "php": "^7.1|^8.0",
                 "scrivo/highlight.php": "^9.15",
                 "symfony/console": "^3.4 || ^4.0",
                 "symfony/var-dumper": "^3.4 || ^4.0"
             },
             "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.14",
-                "mockery/mockery": "^1.2",
+                "mockery/mockery": "~1.3.3|^1.4.2",
                 "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0"
             },
             "suggest": {
                 "laravel",
                 "page"
             ],
-            "time": "2020-03-05T12:39:07+00:00"
+            "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"
+            },
+            "time": "2021-08-02T07:45:03+00:00"
         },
         {
             "name": "facade/ignition-contracts",
-            "version": "1.0.0",
+            "version": "1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/facade/ignition-contracts.git",
-                "reference": "f445db0fb86f48e205787b2592840dd9c80ded28"
+                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/f445db0fb86f48e205787b2592840dd9c80ded28",
-                "reference": "f445db0fb86f48e205787b2592840dd9c80ded28",
+                "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
+                "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "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": {
                 "flare",
                 "ignition"
             ],
-            "time": "2019-08-30T14:06:08+00:00"
+            "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.3.0",
+            "version": "4.4.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/fideloper/TrustedProxy.git",
-                "reference": "ec38ad69ee378a1eec04fb0e417a97cfaf7ed11a"
+                "reference": "c073b2bd04d1c90e04dc1b787662b558dd65ade0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/fideloper/TrustedProxy/zipball/ec38ad69ee378a1eec04fb0e417a97cfaf7ed11a",
-                "reference": "ec38ad69ee378a1eec04fb0e417a97cfaf7ed11a",
+                "url": "https://api.github.com/repos/fideloper/TrustedProxy/zipball/c073b2bd04d1c90e04dc1b787662b558dd65ade0",
+                "reference": "c073b2bd04d1c90e04dc1b787662b558dd65ade0",
                 "shasum": ""
             },
             "require": {
-                "illuminate/contracts": "^5.0|^6.0|^7.0|^8.0",
+                "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",
+                "illuminate/http": "^5.0|^6.0|^7.0|^8.0|^9.0",
                 "mockery/mockery": "^1.0",
                 "phpunit/phpunit": "^6.0"
             },
                 "proxy",
                 "trusted proxy"
             ],
-            "time": "2020-02-22T01:51:47+00:00"
+            "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.7.2",
+            "version": "2.14.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/filp/whoops.git",
-                "reference": "17d0d3f266c8f925ebd035cd36f83cf802b47d4a"
+                "reference": "15ead64e9828f0fc90932114429c4f7923570cb1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/17d0d3f266c8f925ebd035cd36f83cf802b47d4a",
-                "reference": "17d0d3f266c8f925ebd035cd36f83cf802b47d4a",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/15ead64e9828f0fc90932114429c4f7923570cb1",
+                "reference": "15ead64e9828f0fc90932114429c4f7923570cb1",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9 || ^7.0",
+                "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.35 || ^5.7 || ^6.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": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.6-dev"
+                    "dev-master": "2.7-dev"
                 }
             },
             "autoload": {
                 "throwable",
                 "whoops"
             ],
-            "time": "2020-05-05T12:28:07+00:00"
-        },
-        {
-            "name": "gathercontent/htmldiff",
-            "version": "0.2.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/gathercontent/htmldiff.git",
-                "reference": "24674a62315f64330134b4a4c5b01a7b59193c93"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/gathercontent/htmldiff/zipball/24674a62315f64330134b4a4c5b01a7b59193c93",
-                "reference": "24674a62315f64330134b4a4c5b01a7b59193c93",
-                "shasum": ""
-            },
-            "require": {
-                "cogpowered/finediff": "0.3.1",
-                "ext-tidy": "*"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "4.*",
-                "squizlabs/php_codesniffer": "1.*"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-0": {
-                    "GatherContent\\Htmldiff": "src/"
-                }
+            "support": {
+                "issues": "https://github.com/filp/whoops/issues",
+                "source": "https://github.com/filp/whoops/tree/2.14.1"
             },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Andrew Cairns",
-                    "email": "[email protected]"
-                },
-                {
-                    "name": "Mathew Chapman",
-                    "email": "[email protected]"
-                },
+            "funding": [
                 {
-                    "name": "Peter Legierski",
-                    "email": "[email protected]"
+                    "url": "https://github.com/denis-sokolov",
+                    "type": "github"
                 }
             ],
-            "description": "Compare two HTML strings",
-            "time": "2015-04-15T15:39:46+00:00"
+            "time": "2021-08-29T12:00:00+00:00"
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "6.5.3",
+            "version": "7.3.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e"
+                "reference": "7008573787b430c1c1f650e3722d9bba59967628"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aab4ebd862aa7d04f01a4b51849d657db56d882e",
-                "reference": "aab4ebd862aa7d04f01a4b51849d657db56d882e",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7008573787b430c1c1f650e3722d9bba59967628",
+                "reference": "7008573787b430c1c1f650e3722d9bba59967628",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "guzzlehttp/promises": "^1.0",
-                "guzzlehttp/psr7": "^1.6.1",
-                "php": ">=5.5",
-                "symfony/polyfill-intl-idn": "^1.11"
+                "guzzlehttp/promises": "^1.4",
+                "guzzlehttp/psr7": "^1.7 || ^2.0",
+                "php": "^7.2.5 || ^8.0",
+                "psr/http-client": "^1.0"
+            },
+            "provide": {
+                "psr/http-client-implementation": "1.0"
             },
             "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.4.1",
                 "ext-curl": "*",
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0",
+                "php-http/client-integration-tests": "^3.0",
+                "phpunit/phpunit": "^8.5.5 || ^9.3.5",
                 "psr/log": "^1.1"
             },
             "suggest": {
+                "ext-curl": "Required for CURL handler support",
+                "ext-intl": "Required for Internationalized Domain Name (IDN) support",
                 "psr/log": "Required for using the Log middleware"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "6.5-dev"
+                    "dev-master": "7.3-dev"
                 }
             },
             "autoload": {
                     "name": "Michael Dowling",
                     "email": "[email protected]",
                     "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "[email protected]",
+                    "homepage": "https://sagikazarmark.hu"
                 }
             ],
             "description": "Guzzle is a PHP HTTP client library",
                 "framework",
                 "http",
                 "http client",
+                "psr-18",
+                "psr-7",
                 "rest",
                 "web service"
             ],
-            "time": "2020-04-18T10:38:46+00:00"
-        },
-        {
-            "name": "guzzlehttp/promises",
-            "version": "v1.3.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/guzzle/promises.git",
-                "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646",
-                "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.5.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.0"
+            "support": {
+                "issues": "https://github.com/guzzle/guzzle/issues",
+                "source": "https://github.com/guzzle/guzzle/tree/7.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/alexeyshockov",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/gmponos",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-03-23T11:33:13+00:00"
+        },
+        {
+            "name": "guzzlehttp/promises",
+            "version": "1.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/promises.git",
+                "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d",
+                "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.5"
+            },
+            "require-dev": {
+                "symfony/phpunit-bridge": "^4.4 || ^5.1"
             },
             "type": "library",
             "extra": {
             "keywords": [
                 "promise"
             ],
-            "time": "2016-12-20T10:07:11+00:00"
+            "support": {
+                "issues": "https://github.com/guzzle/promises/issues",
+                "source": "https://github.com/guzzle/promises/tree/1.4.1"
+            },
+            "time": "2021-03-07T09:25:29+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
-            "version": "1.6.1",
+            "version": "1.8.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/psr7.git",
-                "reference": "239400de7a173fe9901b9ac7c06497751f00727a"
+                "reference": "dc960a912984efb74d0a90222870c72c87f10c91"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a",
-                "reference": "239400de7a173fe9901b9ac7c06497751f00727a",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91",
+                "reference": "dc960a912984efb74d0a90222870c72c87f10c91",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "ext-zlib": "*",
-                "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8"
+                "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10"
             },
             "suggest": {
-                "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses"
+                "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.6-dev"
+                    "dev-master": "1.7-dev"
                 }
             },
             "autoload": {
                 "uri",
                 "url"
             ],
-            "time": "2019-07-01T23:21:34+00:00"
+            "support": {
+                "issues": "https://github.com/guzzle/psr7/issues",
+                "source": "https://github.com/guzzle/psr7/tree/1.8.2"
+            },
+            "time": "2021-04-26T09:17:50+00:00"
         },
         {
             "name": "intervention/image",
-            "version": "2.5.1",
+            "version": "2.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Intervention/image.git",
-                "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e"
+                "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Intervention/image/zipball/abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
-                "reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
+                "url": "https://api.github.com/repos/Intervention/image/zipball/0925f10b259679b5d8ca58f3a2add9255ffcda45",
+                "reference": "0925f10b259679b5d8ca58f3a2add9255ffcda45",
                 "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.",
                 "thumbnail",
                 "watermark"
             ],
-            "time": "2019-11-02T09:15:47+00:00"
-        },
-        {
-            "name": "jakub-onderka/php-console-color",
-            "version": "v0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/JakubOnderka/PHP-Console-Color.git",
-                "reference": "d5deaecff52a0d61ccb613bb3804088da0307191"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/JakubOnderka/PHP-Console-Color/zipball/d5deaecff52a0d61ccb613bb3804088da0307191",
-                "reference": "d5deaecff52a0d61ccb613bb3804088da0307191",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "jakub-onderka/php-code-style": "1.0",
-                "jakub-onderka/php-parallel-lint": "1.0",
-                "jakub-onderka/php-var-dump-check": "0.*",
-                "phpunit/phpunit": "~4.3",
-                "squizlabs/php_codesniffer": "1.*"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "JakubOnderka\\PhpConsoleColor\\": "src/"
-                }
+            "support": {
+                "issues": "https://github.com/Intervention/image/issues",
+                "source": "https://github.com/Intervention/image/tree/2.6.1"
             },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-2-Clause"
-            ],
-            "authors": [
+            "funding": [
                 {
-                    "name": "Jakub Onderka",
-                    "email": "[email protected]"
-                }
-            ],
-            "abandoned": "php-parallel-lint/php-console-color",
-            "time": "2018-09-29T17:23:10+00:00"
-        },
-        {
-            "name": "jakub-onderka/php-console-highlighter",
-            "version": "v0.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/JakubOnderka/PHP-Console-Highlighter.git",
-                "reference": "9f7a229a69d52506914b4bc61bfdb199d90c5547"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/JakubOnderka/PHP-Console-Highlighter/zipball/9f7a229a69d52506914b4bc61bfdb199d90c5547",
-                "reference": "9f7a229a69d52506914b4bc61bfdb199d90c5547",
-                "shasum": ""
-            },
-            "require": {
-                "ext-tokenizer": "*",
-                "jakub-onderka/php-console-color": "~0.2",
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "jakub-onderka/php-code-style": "~1.0",
-                "jakub-onderka/php-parallel-lint": "~1.0",
-                "jakub-onderka/php-var-dump-check": "~0.1",
-                "phpunit/phpunit": "~4.0",
-                "squizlabs/php_codesniffer": "~1.5"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "JakubOnderka\\PhpConsoleHighlighter\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
+                    "url": "https://www.paypal.me/interventionphp",
+                    "type": "custom"
+                },
                 {
-                    "name": "Jakub Onderka",
-                    "email": "[email protected]",
-                    "homepage": "http://www.acci.cz/"
+                    "url": "https://github.com/Intervention",
+                    "type": "github"
                 }
             ],
-            "description": "Highlight PHP code in terminal",
-            "abandoned": "php-parallel-lint/php-console-highlighter",
-            "time": "2018-09-29T18:48:56+00:00"
+            "time": "2021-07-22T14:31:53+00:00"
         },
         {
             "name": "knplabs/knp-snappy",
                 "thumbnail",
                 "wkhtmltopdf"
             ],
+            "support": {
+                "issues": "https://github.com/KnpLabs/snappy/issues",
+                "source": "https://github.com/KnpLabs/snappy/tree/master"
+            },
             "time": "2020-01-20T08:30:30+00:00"
         },
         {
             "name": "laravel/framework",
-            "version": "v6.18.15",
+            "version": "v6.20.34",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "a1fa3ddc0bb5285cafa6844b443633f7627d75dc"
+                "reference": "72a6da88c90cee793513b3fe49cf0fcb368eefa0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/a1fa3ddc0bb5285cafa6844b443633f7627d75dc",
-                "reference": "a1fa3ddc0bb5285cafa6844b443633f7627d75dc",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/72a6da88c90cee793513b3fe49cf0fcb368eefa0",
+                "reference": "72a6da88c90cee793513b3fe49cf0fcb368eefa0",
                 "shasum": ""
             },
             "require": {
                 "doctrine/inflector": "^1.4|^2.0",
-                "dragonmantank/cron-expression": "^2.0",
+                "dragonmantank/cron-expression": "^2.3.1",
                 "egulias/email-validator": "^2.1.10",
                 "ext-json": "*",
                 "ext-mbstring": "*",
                 "ext-openssl": "*",
                 "league/commonmark": "^1.3",
-                "league/flysystem": "^1.0.8",
+                "league/flysystem": "^1.1",
                 "monolog/monolog": "^1.12|^2.0",
-                "nesbot/carbon": "^2.0",
-                "opis/closure": "^3.1",
-                "php": "^7.2",
+                "nesbot/carbon": "^2.31",
+                "opis/closure": "^3.6",
+                "php": "^7.2.5|^8.0",
                 "psr/container": "^1.0",
                 "psr/simple-cache": "^1.0",
                 "ramsey/uuid": "^3.7",
                 "illuminate/view": "self.version"
             },
             "require-dev": {
-                "aws/aws-sdk-php": "^3.0",
+                "aws/aws-sdk-php": "^3.155",
                 "doctrine/dbal": "^2.6",
-                "filp/whoops": "^2.4",
-                "guzzlehttp/guzzle": "^6.3|^7.0",
+                "filp/whoops": "^2.8",
+                "guzzlehttp/guzzle": "^6.3.1|^7.0.1",
                 "league/flysystem-cached-adapter": "^1.0",
-                "mockery/mockery": "^1.3.1",
+                "mockery/mockery": "~1.3.3|^1.4.2",
                 "moontoast/math": "^1.1",
-                "orchestra/testbench-core": "^4.0",
+                "orchestra/testbench-core": "^4.8",
                 "pda/pheanstalk": "^4.0",
-                "phpunit/phpunit": "^7.5.15|^8.4|^9.0",
+                "phpunit/phpunit": "^7.5.15|^8.4|^9.3.3",
                 "predis/predis": "^1.1.1",
                 "symfony/cache": "^4.3.4"
             },
             "suggest": {
-                "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.0).",
+                "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).",
+                "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-pcntl": "Required to use all features of the queue worker.",
                 "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).",
-                "filp/whoops": "Required for friendly error pages in development (^2.4).",
-                "fzaninotto/faker": "Required to use the eloquent factory builder (^1.9.1).",
-                "guzzlehttp/guzzle": "Required to use the Mailgun mail driver and the ping methods on schedules (^6.0|^7.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).",
                 "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).",
                 "moontoast/math": "Required to use ordered UUIDs (^1.1).",
                 "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).",
                 "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).",
                 "framework",
                 "laravel"
             ],
-            "time": "2020-05-19T17:03:02+00:00"
+            "support": {
+                "issues": "https://github.com/laravel/framework/issues",
+                "source": "https://github.com/laravel/framework"
+            },
+            "time": "2021-09-07T13:28:55+00:00"
         },
         {
             "name": "laravel/socialite",
-            "version": "v4.3.2",
+            "version": "v5.2.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/socialite.git",
-                "reference": "4bd66ee416fea04398dee5b8c32d65719a075db4"
+                "reference": "fd0f6a3dd963ca480b598649b54f92d81a43617f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/socialite/zipball/4bd66ee416fea04398dee5b8c32d65719a075db4",
-                "reference": "4bd66ee416fea04398dee5b8c32d65719a075db4",
+                "url": "https://api.github.com/repos/laravel/socialite/zipball/fd0f6a3dd963ca480b598649b54f92d81a43617f",
+                "reference": "fd0f6a3dd963ca480b598649b54f92d81a43617f",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "guzzlehttp/guzzle": "~6.0",
-                "illuminate/http": "~5.7.0|~5.8.0|^6.0|^7.0",
-                "illuminate/support": "~5.7.0|~5.8.0|^6.0|^7.0",
-                "league/oauth1-client": "~1.0",
-                "php": "^7.1.3"
+                "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"
             },
             "require-dev": {
-                "illuminate/contracts": "~5.7.0|~5.8.0|^6.0|^7.0",
+                "illuminate/contracts": "^6.0|^7.0",
                 "mockery/mockery": "^1.0",
-                "phpunit/phpunit": "^7.0|^8.0"
+                "orchestra/testbench": "^4.0|^5.0|^6.0",
+                "phpunit/phpunit": "^8.0|^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "4.x-dev"
+                    "dev-master": "5.x-dev"
                 },
                 "laravel": {
                     "providers": [
                 "laravel",
                 "oauth"
             ],
-            "time": "2020-02-04T15:30:01+00:00"
+            "support": {
+                "issues": "https://github.com/laravel/socialite/issues",
+                "source": "https://github.com/laravel/socialite"
+            },
+            "time": "2021-08-31T15:16:26+00:00"
         },
         {
             "name": "lcobucci/jwt",
         },
         {
             "name": "league/commonmark",
-            "version": "1.4.3",
+            "version": "1.6.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/commonmark.git",
-                "reference": "412639f7cfbc0b31ad2455b2fe965095f66ae505"
+                "reference": "c4228d11e30d7493c6836d20872f9582d8ba6dcf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/412639f7cfbc0b31ad2455b2fe965095f66ae505",
-                "reference": "412639f7cfbc0b31ad2455b2fe965095f66ae505",
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/c4228d11e30d7493c6836d20872f9582d8ba6dcf",
+                "reference": "c4228d11e30d7493c6836d20872f9582d8ba6dcf",
                 "shasum": ""
             },
             "require": {
                 "ext-mbstring": "*",
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "conflict": {
                 "scrutinizer/ocular": "1.7.*"
             },
             "require-dev": {
                 "cebe/markdown": "~1.0",
-                "commonmark/commonmark.js": "0.29.1",
+                "commonmark/commonmark.js": "0.29.2",
                 "erusev/parsedown": "~1.0",
                 "ext-json": "*",
                 "github/gfm": "0.29.0",
                 "michelf/php-markdown": "~1.4",
                 "mikehaertl/php-shellcommand": "^1.4",
-                "phpstan/phpstan": "^0.12",
-                "phpunit/phpunit": "^7.5",
+                "phpstan/phpstan": "^0.12.90",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.2",
                 "scrutinizer/ocular": "^1.5",
                 "symfony/finder": "^4.2"
             },
                 "bin/commonmark"
             ],
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "League\\CommonMark\\": "src"
                 "md",
                 "parser"
             ],
+            "support": {
+                "docs": "https://commonmark.thephpleague.com/",
+                "issues": "https://github.com/thephpleague/commonmark/issues",
+                "rss": "https://github.com/thephpleague/commonmark/releases.atom",
+                "source": "https://github.com/thephpleague/commonmark"
+            },
             "funding": [
                 {
                     "url": "https://enjoy.gitstore.app/repositories/thephpleague/commonmark",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-04T22:15:21+00:00"
+            "time": "2021-07-17T17:13:23+00:00"
         },
         {
             "name": "league/flysystem",
-            "version": "1.0.69",
+            "version": "1.1.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem.git",
-                "reference": "7106f78428a344bc4f643c233a94e48795f10967"
+                "reference": "18634df356bfd4119fe3d6156bdb990c414c14ea"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/7106f78428a344bc4f643c233a94e48795f10967",
-                "reference": "7106f78428a344bc4f643c233a94e48795f10967",
+                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/18634df356bfd4119fe3d6156bdb990c414c14ea",
+                "reference": "18634df356bfd4119fe3d6156bdb990c414c14ea",
                 "shasum": ""
             },
             "require": {
                 "ext-fileinfo": "*",
-                "php": ">=5.5.9"
+                "league/mime-type-detection": "^1.3",
+                "php": "^7.2.5 || ^8.0"
             },
             "conflict": {
                 "league/flysystem-sftp": "<1.0.6"
             },
             "require-dev": {
-                "phpspec/phpspec": "^3.4",
-                "phpunit/phpunit": "^5.7.26"
+                "phpspec/prophecy": "^1.11.1",
+                "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",
                 "sftp",
                 "storage"
             ],
+            "support": {
+                "issues": "https://github.com/thephpleague/flysystem/issues",
+                "source": "https://github.com/thephpleague/flysystem/tree/1.1.5"
+            },
             "funding": [
                 {
                     "url": "https://offset.earth/frankdejonge",
                     "type": "other"
                 }
             ],
-            "time": "2020-05-18T15:13:39+00:00"
+            "time": "2021-08-17T13:49:42+00:00"
         },
         {
             "name": "league/flysystem-aws-s3-v3",
-            "version": "1.0.24",
+            "version": "1.0.29",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
-                "reference": "4382036bde5dc926f9b8b337e5bdb15e5ec7b570"
+                "reference": "4e25cc0582a36a786c31115e419c6e40498f6972"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/4382036bde5dc926f9b8b337e5bdb15e5ec7b570",
-                "reference": "4382036bde5dc926f9b8b337e5bdb15e5ec7b570",
+                "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/4e25cc0582a36a786c31115e419c6e40498f6972",
+                "reference": "4e25cc0582a36a786c31115e419c6e40498f6972",
                 "shasum": ""
             },
             "require": {
-                "aws/aws-sdk-php": "^3.0.0",
+                "aws/aws-sdk-php": "^3.20.0",
                 "league/flysystem": "^1.0.40",
                 "php": ">=5.5.0"
             },
                 }
             ],
             "description": "Flysystem adapter for the AWS S3 SDK v3.x",
-            "time": "2020-02-23T13:31:58+00:00"
+            "support": {
+                "issues": "https://github.com/thephpleague/flysystem-aws-s3-v3/issues",
+                "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/1.0.29"
+            },
+            "time": "2020-10-08T18:58:37+00:00"
         },
         {
-            "name": "league/oauth1-client",
+            "name": "league/html-to-markdown",
+            "version": "5.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/html-to-markdown.git",
+                "reference": "e5600a2c5ce7b7571b16732c7086940f56f7abec"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/e5600a2c5ce7b7571b16732c7086940f56f7abec",
+                "reference": "e5600a2c5ce7b7571b16732c7086940f56f7abec",
+                "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.1"
+            },
+            "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-09-17T20:00:27+00:00"
+        },
+        {
+            "name": "league/mime-type-detection",
             "version": "1.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/mime-type-detection.git",
+                "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3",
+                "reference": "3b9dff8aaf7323590c1d2e443db701eb1f9aa0d3",
+                "shasum": ""
+            },
+            "require": {
+                "ext-fileinfo": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^2.18",
+                "phpstan/phpstan": "^0.12.68",
+                "phpunit/phpunit": "^8.5.8 || ^9.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "League\\MimeTypeDetection\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Frank de Jonge",
+                    "email": "[email protected]"
+                }
+            ],
+            "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"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/frankdejonge",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/league/flysystem",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-01-18T20:58:21+00:00"
+        },
+        {
+            "name": "league/oauth1-client",
+            "version": "v1.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/oauth1-client.git",
-                "reference": "fca5f160650cb74d23fc11aa570dd61f86dcf647"
+                "reference": "88dd16b0cff68eb9167bfc849707d2c40ad91ddc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/fca5f160650cb74d23fc11aa570dd61f86dcf647",
-                "reference": "fca5f160650cb74d23fc11aa570dd61f86dcf647",
+                "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/88dd16b0cff68eb9167bfc849707d2c40ad91ddc",
+                "reference": "88dd16b0cff68eb9167bfc849707d2c40ad91ddc",
                 "shasum": ""
             },
             "require": {
-                "guzzlehttp/guzzle": "^6.0",
-                "php": ">=5.5.0"
+                "ext-json": "*",
+                "ext-openssl": "*",
+                "guzzlehttp/guzzle": "^6.0|^7.0",
+                "guzzlehttp/psr7": "^1.7|^2.0",
+                "php": ">=7.1||>=8.0"
             },
             "require-dev": {
-                "mockery/mockery": "^0.9",
-                "phpunit/phpunit": "^4.0",
-                "squizlabs/php_codesniffer": "^2.0"
+                "ext-simplexml": "*",
+                "friendsofphp/php-cs-fixer": "^2.17",
+                "mockery/mockery": "^1.3.3",
+                "phpstan/phpstan": "^0.12.42",
+                "phpunit/phpunit": "^7.5||9.5"
+            },
+            "suggest": {
+                "ext-simplexml": "For decoding XML-based responses."
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0-dev"
+                    "dev-master": "1.0-dev",
+                    "dev-develop": "2.0-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "League\\OAuth1\\": "src/"
+                    "League\\OAuth1\\Client\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 "tumblr",
                 "twitter"
             ],
-            "time": "2016-08-17T00:36:58+00:00"
+            "support": {
+                "issues": "https://github.com/thephpleague/oauth1-client/issues",
+                "source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.0"
+            },
+            "time": "2021-08-15T23:05:49+00:00"
         },
         {
             "name": "league/oauth2-client",
         },
         {
             "name": "monolog/monolog",
-            "version": "2.1.0",
+            "version": "2.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "38914429aac460e8e4616c8cb486ecb40ec90bb1"
+                "reference": "437e7a1c50044b92773b361af77620efb76fff59"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/38914429aac460e8e4616c8cb486ecb40ec90bb1",
-                "reference": "38914429aac460e8e4616c8cb486ecb40ec90bb1",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/437e7a1c50044b92773b361af77620efb76fff59",
+                "reference": "437e7a1c50044b92773b361af77620efb76fff59",
                 "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",
                 "doctrine/couchdb": "~1.0@dev",
-                "elasticsearch/elasticsearch": "^6.0",
+                "elasticsearch/elasticsearch": "^7",
                 "graylog2/gelf-php": "^1.4.2",
+                "mongodb/mongodb": "^1.8",
                 "php-amqplib/php-amqplib": "~2.4",
                 "php-console/php-console": "^3.1.3",
-                "php-parallel-lint/php-parallel-lint": "^1.0",
                 "phpspec/prophecy": "^1.6.1",
+                "phpstan/phpstan": "^0.12.91",
                 "phpunit/phpunit": "^8.5",
                 "predis/predis": "^1.1",
                 "rollbar/rollbar": "^1.3",
-                "ruflin/elastica": ">=0.90 <3.0",
+                "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",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.x-dev"
+                    "dev-main": "2.x-dev"
                 }
             },
             "autoload": {
                 {
                     "name": "Jordi Boggiano",
                     "email": "[email protected]",
-                    "homepage": "http://seld.be"
+                    "homepage": "https://seld.be"
                 }
             ],
             "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
-            "homepage": "http://github.com/Seldaek/monolog",
+            "homepage": "https://github.com/Seldaek/monolog",
             "keywords": [
                 "log",
                 "logging",
                 "psr-3"
             ],
+            "support": {
+                "issues": "https://github.com/Seldaek/monolog/issues",
+                "source": "https://github.com/Seldaek/monolog/tree/2.3.4"
+            },
             "funding": [
                 {
                     "url": "https://github.com/Seldaek",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-22T08:12:19+00:00"
+            "time": "2021-09-15T11:27:21+00:00"
         },
         {
             "name": "mtdowling/jmespath.php",
-            "version": "2.5.0",
+            "version": "2.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/jmespath/jmespath.php.git",
-                "reference": "52168cb9472de06979613d365c7f1ab8798be895"
+                "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/52168cb9472de06979613d365c7f1ab8798be895",
-                "reference": "52168cb9472de06979613d365c7f1ab8798be895",
+                "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/9b87907a81b87bc76d19a7fb2d61e61486ee9edb",
+                "reference": "9b87907a81b87bc76d19a7fb2d61e61486ee9edb",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.4.0",
-                "symfony/polyfill-mbstring": "^1.4"
+                "php": "^5.4 || ^7.0 || ^8.0",
+                "symfony/polyfill-mbstring": "^1.17"
             },
             "require-dev": {
-                "composer/xdebug-handler": "^1.2",
-                "phpunit/phpunit": "^4.8.36|^7.5.15"
+                "composer/xdebug-handler": "^1.4 || ^2.0",
+                "phpunit/phpunit": "^4.8.36 || ^7.5.15"
             },
             "bin": [
                 "bin/jp.php"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.5-dev"
+                    "dev-master": "2.6-dev"
                 }
             },
             "autoload": {
                 "json",
                 "jsonpath"
             ],
-            "time": "2019-12-30T18:03:34+00:00"
+            "support": {
+                "issues": "https://github.com/jmespath/jmespath.php/issues",
+                "source": "https://github.com/jmespath/jmespath.php/tree/2.6.1"
+            },
+            "time": "2021-06-14T00:11:39+00:00"
         },
         {
             "name": "nesbot/carbon",
-            "version": "2.34.2",
+            "version": "2.53.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/briannesbitt/Carbon.git",
-                "reference": "3e87404329b8072295ea11d548b47a1eefe5a162"
+                "reference": "f4655858a784988f880c1b8c7feabbf02dfdf045"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/3e87404329b8072295ea11d548b47a1eefe5a162",
-                "reference": "3e87404329b8072295ea11d548b47a1eefe5a162",
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/f4655858a784988f880c1b8c7feabbf02dfdf045",
+                "reference": "f4655858a784988f880c1b8c7feabbf02dfdf045",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "php": "^7.1.8 || ^8.0",
                 "symfony/polyfill-mbstring": "^1.0",
+                "symfony/polyfill-php80": "^1.16",
                 "symfony/translation": "^3.4 || ^4.0 || ^5.0"
             },
             "require-dev": {
                 "doctrine/orm": "^2.7",
-                "friendsofphp/php-cs-fixer": "^2.14 || ^3.0",
-                "kylekatarnls/multi-tester": "^1.1",
-                "phpmd/phpmd": "^2.8",
-                "phpstan/phpstan": "^0.11",
-                "phpunit/phpunit": "^7.5 || ^8.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",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.14",
                 "squizlabs/php_codesniffer": "^3.4"
             },
             "bin": [
             "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": [
                         "Carbon\\Laravel\\ServiceProvider"
                     ]
+                },
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
                 }
             },
             "autoload": {
                 {
                     "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": {
+                "issues": "https://github.com/briannesbitt/Carbon/issues",
+                "source": "https://github.com/briannesbitt/Carbon"
+            },
             "funding": [
                 {
                     "url": "https://opencollective.com/Carbon",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-19T22:14:16+00:00"
+            "time": "2021-09-06T09:29:23+00:00"
         },
         {
             "name": "nunomaduro/collision",
-            "version": "v3.0.1",
+            "version": "v3.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nunomaduro/collision.git",
-                "reference": "af42d339fe2742295a54f6fdd42aaa6f8c4aca68"
+                "reference": "f7c45764dfe4ba5f2618d265a6f1f9c72732e01d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/af42d339fe2742295a54f6fdd42aaa6f8c4aca68",
-                "reference": "af42d339fe2742295a54f6fdd42aaa6f8c4aca68",
+                "url": "https://api.github.com/repos/nunomaduro/collision/zipball/f7c45764dfe4ba5f2618d265a6f1f9c72732e01d",
+                "reference": "f7c45764dfe4ba5f2618d265a6f1f9c72732e01d",
                 "shasum": ""
             },
             "require": {
                 "filp/whoops": "^2.1.4",
-                "jakub-onderka/php-console-highlighter": "0.3.*|0.4.*",
-                "php": "^7.1",
+                "php": "^7.2.5 || ^8.0",
+                "php-parallel-lint/php-console-highlighter": "0.5.*",
                 "symfony/console": "~2.8|~3.3|~4.0"
             },
             "require-dev": {
-                "laravel/framework": "5.8.*",
-                "nunomaduro/larastan": "^0.3.0",
-                "phpstan/phpstan": "^0.11",
-                "phpunit/phpunit": "~8.0"
+                "laravel/framework": "^6.0",
+                "phpunit/phpunit": "^8.0 || ^9.0"
             },
             "type": "library",
             "extra": {
                 "php",
                 "symfony"
             ],
-            "time": "2019-03-07T21:35:13+00:00"
+            "support": {
+                "issues": "https://github.com/nunomaduro/collision/issues",
+                "source": "https://github.com/nunomaduro/collision"
+            },
+            "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"
         },
         {
             "name": "onelogin/php-saml",
-            "version": "3.4.1",
+            "version": "4.0.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/onelogin/php-saml.git",
-                "reference": "5fbf3486704ac9835b68184023ab54862c95f213"
+                "reference": "f30f5062f3653c4d2082892d207f4dc3e577d979"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/onelogin/php-saml/zipball/5fbf3486704ac9835b68184023ab54862c95f213",
-                "reference": "5fbf3486704ac9835b68184023ab54862c95f213",
+                "url": "https://api.github.com/repos/onelogin/php-saml/zipball/f30f5062f3653c4d2082892d207f4dc3e577d979",
+                "reference": "f30f5062f3653c4d2082892d207f4dc3e577d979",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.4",
-                "robrichards/xmlseclibs": ">=3.0.4"
+                "php": ">=7.3",
+                "robrichards/xmlseclibs": ">=3.1.1"
             },
             "require-dev": {
-                "pdepend/pdepend": "^2.5.0",
-                "php-coveralls/php-coveralls": "^1.0.2 || ^2.0",
-                "phploc/phploc": "^2.1 || ^3.0 || ^4.0",
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1",
-                "sebastian/phpcpd": "^2.0 || ^3.0 || ^4.0",
-                "squizlabs/php_codesniffer": "^3.1.1"
+                "pdepend/pdepend": "^2.8.0",
+                "php-coveralls/php-coveralls": "^2.0",
+                "phploc/phploc": "^4.0 || ^5.0 || ^6.0 || ^7.0",
+                "phpunit/phpunit": "^9.5",
+                "sebastian/phpcpd": "^4.0 || ^5.0 || ^6.0 ",
+                "squizlabs/php_codesniffer": "^3.5.8"
             },
             "suggest": {
                 "ext-curl": "Install curl lib to be able to use the IdPMetadataParser for parsing remote XMLs",
-                "ext-gettext": "Install gettext and php5-gettext libs to handle translations",
-                "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)"
+                "ext-dom": "Install xml lib",
+                "ext-openssl": "Install openssl lib in order to handle with x509 certs (require to support sign and encryption)",
+                "ext-zlib": "Install zlib"
             },
             "type": "library",
             "autoload": {
                 "onelogin",
                 "saml"
             ],
-            "time": "2019-11-25T17:30:07+00:00"
+            "support": {
+                "email": "[email protected]",
+                "issues": "https://github.com/onelogin/php-saml/issues",
+                "source": "https://github.com/onelogin/php-saml/"
+            },
+            "time": "2021-03-02T10:19:19+00:00"
         },
         {
             "name": "opis/closure",
-            "version": "3.5.2",
+            "version": "3.6.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/opis/closure.git",
-                "reference": "2e3299cea6f485ca64d19c540f46d7896c512ace"
+                "reference": "06e2ebd25f2869e54a306dda991f7db58066f7f6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/opis/closure/zipball/2e3299cea6f485ca64d19c540f46d7896c512ace",
-                "reference": "2e3299cea6f485ca64d19c540f46d7896c512ace",
+                "url": "https://api.github.com/repos/opis/closure/zipball/06e2ebd25f2869e54a306dda991f7db58066f7f6",
+                "reference": "06e2ebd25f2869e54a306dda991f7db58066f7f6",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.4 || ^7.0"
+                "php": "^5.4 || ^7.0 || ^8.0"
             },
             "require-dev": {
                 "jeremeamia/superclosure": "^2.0",
-                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.5.x-dev"
+                    "dev-master": "3.6.x-dev"
                 }
             },
             "autoload": {
                 "serialization",
                 "serialize"
             ],
-            "time": "2020-05-21T20:09:36+00:00"
+            "support": {
+                "issues": "https://github.com/opis/closure/issues",
+                "source": "https://github.com/opis/closure/tree/3.6.2"
+            },
+            "time": "2021-04-09T13:42:10+00:00"
         },
         {
-            "name": "paragonie/random_compat",
-            "version": "v9.99.99",
+            "name": "paragonie/constant_time_encoding",
+            "version": "v2.4.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/paragonie/random_compat.git",
-                "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95"
+                "url": "https://github.com/paragonie/constant_time_encoding.git",
+                "reference": "f34c2b11eb9d2c9318e13540a1dbc2a3afbd939c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95",
-                "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95",
+                "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/f34c2b11eb9d2c9318e13540a1dbc2a3afbd939c",
+                "reference": "f34c2b11eb9d2c9318e13540a1dbc2a3afbd939c",
                 "shasum": ""
             },
             "require": {
-                "php": "^7"
+                "php": "^7|^8"
             },
             "require-dev": {
-                "phpunit/phpunit": "4.*|5.*",
-                "vimeo/psalm": "^1"
+                "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": "2020-12-06T15:14:20+00:00"
+        },
+        {
+            "name": "paragonie/random_compat",
+            "version": "v9.99.100",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/random_compat.git",
+                "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+                "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">= 7"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "4.*|5.*",
+                "vimeo/psalm": "^1"
             },
             "suggest": {
                 "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
                 "pseudorandom",
                 "random"
             ],
-            "time": "2018-07-02T15:55:56+00:00"
+            "support": {
+                "email": "[email protected]",
+                "issues": "https://github.com/paragonie/random_compat/issues",
+                "source": "https://github.com/paragonie/random_compat"
+            },
+            "time": "2020-10-15T08:29:30+00:00"
         },
         {
             "name": "phenx/php-font-lib",
             ],
             "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"
+            },
             "time": "2020-03-08T15:31:32+00:00"
         },
         {
             ],
             "description": "A library to read, parse and export to PDF SVG files.",
             "homepage": "https://github.com/PhenX/php-svg-lib",
+            "support": {
+                "issues": "https://github.com/PhenX/php-svg-lib/issues",
+                "source": "https://github.com/PhenX/php-svg-lib/tree/master"
+            },
             "time": "2019-09-11T20:02:13+00:00"
         },
+        {
+            "name": "php-parallel-lint/php-console-color",
+            "version": "v0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-parallel-lint/PHP-Console-Color.git",
+                "reference": "b6af326b2088f1ad3b264696c9fd590ec395b49e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-parallel-lint/PHP-Console-Color/zipball/b6af326b2088f1ad3b264696c9fd590ec395b49e",
+                "reference": "b6af326b2088f1ad3b264696c9fd590ec395b49e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4.0"
+            },
+            "replace": {
+                "jakub-onderka/php-console-color": "*"
+            },
+            "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.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "JakubOnderka\\PhpConsoleColor\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Jakub Onderka",
+                    "email": "[email protected]"
+                }
+            ],
+            "support": {
+                "issues": "https://github.com/php-parallel-lint/PHP-Console-Color/issues",
+                "source": "https://github.com/php-parallel-lint/PHP-Console-Color/tree/master"
+            },
+            "time": "2020-05-14T05:47:14+00:00"
+        },
+        {
+            "name": "php-parallel-lint/php-console-highlighter",
+            "version": "v0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-parallel-lint/PHP-Console-Highlighter.git",
+                "reference": "21bf002f077b177f056d8cb455c5ed573adfdbb8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-parallel-lint/PHP-Console-Highlighter/zipball/21bf002f077b177f056d8cb455c5ed573adfdbb8",
+                "reference": "21bf002f077b177f056d8cb455c5ed573adfdbb8",
+                "shasum": ""
+            },
+            "require": {
+                "ext-tokenizer": "*",
+                "php": ">=5.4.0",
+                "php-parallel-lint/php-console-color": "~0.2"
+            },
+            "replace": {
+                "jakub-onderka/php-console-highlighter": "*"
+            },
+            "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"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "JakubOnderka\\PhpConsoleHighlighter\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jakub Onderka",
+                    "email": "[email protected]",
+                    "homepage": "http://www.acci.cz/"
+                }
+            ],
+            "description": "Highlight PHP code in terminal",
+            "support": {
+                "issues": "https://github.com/php-parallel-lint/PHP-Console-Highlighter/issues",
+                "source": "https://github.com/php-parallel-lint/PHP-Console-Highlighter/tree/master"
+            },
+            "time": "2020-05-13T07:37:49+00:00"
+        },
         {
             "name": "phpoption/phpoption",
-            "version": "1.7.3",
+            "version": "1.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/schmittjoh/php-option.git",
-                "reference": "4acfd6a4b33a509d8c88f50e5222f734b6aeebae"
+                "reference": "5455cb38aed4523f99977c4a12ef19da4bfe2a28"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/4acfd6a4b33a509d8c88f50e5222f734b6aeebae",
-                "reference": "4acfd6a4b33a509d8c88f50e5222f734b6aeebae",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/5455cb38aed4523f99977c4a12ef19da4bfe2a28",
+                "reference": "5455cb38aed4523f99977c4a12ef19da4bfe2a28",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9 || ^7.0 || ^8.0"
+                "php": "^7.0 || ^8.0"
             },
             "require-dev": {
-                "bamarni/composer-bin-plugin": "^1.3",
-                "phpunit/phpunit": "^4.8.35 || ^5.0 || ^6.0 || ^7.0"
+                "bamarni/composer-bin-plugin": "^1.4.1",
+                "phpunit/phpunit": "^6.5.14 || ^7.0.20 || ^8.5.19 || ^9.5.8"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.7-dev"
+                    "dev-master": "1.8-dev"
                 }
             },
             "autoload": {
                 },
                 {
                     "name": "Graham Campbell",
-                    "email": "[email protected]"
+                    "email": "[email protected]"
                 }
             ],
             "description": "Option Type for PHP",
                 "php",
                 "type"
             ],
-            "time": "2020-03-21T18:07:53+00:00"
+            "support": {
+                "issues": "https://github.com/schmittjoh/php-option/issues",
+                "source": "https://github.com/schmittjoh/php-option/tree/1.8.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-08-28T21:27:29+00:00"
+        },
+        {
+            "name": "pragmarx/google2fa",
+            "version": "8.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/antonioribeiro/google2fa.git",
+                "reference": "26c4c5cf30a2844ba121760fd7301f8ad240100b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/26c4c5cf30a2844ba121760fd7301f8ad240100b",
+                "reference": "26c4c5cf30a2844ba121760fd7301f8ad240100b",
+                "shasum": ""
+            },
+            "require": {
+                "paragonie/constant_time_encoding": "^1.0|^2.0",
+                "php": "^7.1|^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^0.12.18",
+                "phpunit/phpunit": "^7.5.15|^8.5|^9.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PragmaRX\\Google2FA\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Antonio Carlos Ribeiro",
+                    "email": "[email protected]",
+                    "role": "Creator & Designer"
+                }
+            ],
+            "description": "A One Time Password Authentication package, compatible with Google Authenticator.",
+            "keywords": [
+                "2fa",
+                "Authentication",
+                "Two Factor Authentication",
+                "google2fa"
+            ],
+            "support": {
+                "issues": "https://github.com/antonioribeiro/google2fa/issues",
+                "source": "https://github.com/antonioribeiro/google2fa/tree/8.0.0"
+            },
+            "time": "2020-04-05T10:47:18+00:00"
         },
         {
             "name": "predis/predis",
-            "version": "v1.1.1",
+            "version": "v1.1.7",
             "source": {
                 "type": "git",
-                "url": "https://github.com/nrk/predis.git",
-                "reference": "f0210e38881631afeafb56ab43405a92cafd9fd1"
+                "url": "https://github.com/predis/predis.git",
+                "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nrk/predis/zipball/f0210e38881631afeafb56ab43405a92cafd9fd1",
-                "reference": "f0210e38881631afeafb56ab43405a92cafd9fd1",
+                "url": "https://api.github.com/repos/predis/predis/zipball/b240daa106d4e02f0c5b7079b41e31ddf66fddf8",
+                "reference": "b240daa106d4e02f0c5b7079b41e31ddf66fddf8",
                 "shasum": ""
             },
             "require": {
                 {
                     "name": "Daniele Alessandri",
                     "email": "[email protected]",
-                    "homepage": "http://clorophilla.net"
+                    "homepage": "http://clorophilla.net",
+                    "role": "Creator & Maintainer"
+                },
+                {
+                    "name": "Till Krüss",
+                    "homepage": "https://till.im",
+                    "role": "Maintainer"
                 }
             ],
             "description": "Flexible and feature-complete Redis client for PHP and HHVM",
-            "homepage": "http://github.com/nrk/predis",
+            "homepage": "http://github.com/predis/predis",
             "keywords": [
                 "nosql",
                 "predis",
                 "redis"
             ],
-            "time": "2016-06-16T16:22:20+00:00"
+            "support": {
+                "issues": "https://github.com/predis/predis/issues",
+                "source": "https://github.com/predis/predis/tree/v1.1.7"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sponsors/tillkruss",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-04-04T19:34:46+00:00"
         },
         {
             "name": "psr/container",
-            "version": "1.0.0",
+            "version": "1.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/container.git",
-                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
+                "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
-                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf",
+                "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.0"
+                "php": ">=7.2.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Psr\\Container\\": "src/"
             "authors": [
                 {
                     "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
+                    "homepage": "https://www.php-fig.org/"
                 }
             ],
             "description": "Common Container Interface (PHP FIG PSR-11)",
                 "container-interop",
                 "psr"
             ],
-            "time": "2017-02-14T16:28:37+00:00"
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/1.1.1"
+            },
+            "time": "2021-03-05T17:36:06+00:00"
+        },
+        {
+            "name": "psr/http-client",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-client.git",
+                "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+                "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.0 || ^8.0",
+                "psr/http-message": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP clients",
+            "homepage": "https://github.com/php-fig/http-client",
+            "keywords": [
+                "http",
+                "http-client",
+                "psr",
+                "psr-18"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-client/tree/master"
+            },
+            "time": "2020-06-29T06:28:15+00:00"
         },
         {
             "name": "psr/http-message",
                 "request",
                 "response"
             ],
+            "support": {
+                "source": "https://github.com/php-fig/http-message/tree/master"
+            },
             "time": "2016-08-06T14:39:51+00:00"
         },
         {
             "name": "psr/log",
-            "version": "1.1.3",
+            "version": "1.1.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
+                "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
-                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
+                "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
                 "shasum": ""
             },
             "require": {
             "authors": [
                 {
                     "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
+                    "homepage": "https://www.php-fig.org/"
                 }
             ],
             "description": "Common interface for logging libraries",
                 "psr",
                 "psr-3"
             ],
-            "time": "2020-03-23T09:12:05+00:00"
+            "support": {
+                "source": "https://github.com/php-fig/log/tree/1.1.4"
+            },
+            "time": "2021-05-03T11:20:27+00:00"
         },
         {
             "name": "psr/simple-cache",
                 "psr-16",
                 "simple-cache"
             ],
+            "support": {
+                "source": "https://github.com/php-fig/simple-cache/tree/master"
+            },
             "time": "2017-10-23T01:57:42+00:00"
         },
         {
                 }
             ],
             "description": "A polyfill for getallheaders.",
+            "support": {
+                "issues": "https://github.com/ralouphie/getallheaders/issues",
+                "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+            },
             "time": "2019-03-08T08:55:37+00:00"
         },
         {
             "name": "ramsey/uuid",
-            "version": "3.9.3",
+            "version": "3.9.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/ramsey/uuid.git",
-                "reference": "7e1633a6964b48589b142d60542f9ed31bd37a92"
+                "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ramsey/uuid/zipball/7e1633a6964b48589b142d60542f9ed31bd37a92",
-                "reference": "7e1633a6964b48589b142d60542f9ed31bd37a92",
+                "url": "https://api.github.com/repos/ramsey/uuid/zipball/be2451bef8147b7352a28fb4cddb08adc497ada3",
+                "reference": "be2451bef8147b7352a28fb4cddb08adc497ada3",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "paragonie/random_compat": "^1 | ^2 | 9.99.99",
+                "paragonie/random_compat": "^1 | ^2 | ^9.99.99",
                 "php": "^5.4 | ^7 | ^8",
                 "symfony/polyfill-ctype": "^1.8"
             },
                 "identifier",
                 "uuid"
             ],
-            "time": "2020-02-21T04:36:14+00:00"
+            "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"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ramsey",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-08-06T20:32:15+00:00"
         },
         {
             "name": "robrichards/xmlseclibs",
-            "version": "3.1.0",
+            "version": "3.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/robrichards/xmlseclibs.git",
-                "reference": "8d8e56ca7914440a8c60caff1a865e7dff1d9a5a"
+                "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/8d8e56ca7914440a8c60caff1a865e7dff1d9a5a",
-                "reference": "8d8e56ca7914440a8c60caff1a865e7dff1d9a5a",
+                "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/f8f19e58f26cdb42c54b214ff8a820760292f8df",
+                "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df",
                 "shasum": ""
             },
             "require": {
                 "xml",
                 "xmldsig"
             ],
-            "time": "2020-04-22T17:19:51+00:00"
+            "support": {
+                "issues": "https://github.com/robrichards/xmlseclibs/issues",
+                "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.1"
+            },
+            "time": "2020-09-05T13:00:25+00:00"
         },
         {
             "name": "sabberworm/php-css-parser",
-            "version": "8.3.0",
+            "version": "8.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sabberworm/PHP-CSS-Parser.git",
-                "reference": "91bcc3e3fdb7386c9a2e0e0aa09ca75cc43f121f"
+                "reference": "d217848e1396ef962fb1997cf3e2421acba7f796"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sabberworm/PHP-CSS-Parser/zipball/91bcc3e3fdb7386c9a2e0e0aa09ca75cc43f121f",
-                "reference": "91bcc3e3fdb7386c9a2e0e0aa09ca75cc43f121f",
+                "url": "https://api.github.com/repos/sabberworm/PHP-CSS-Parser/zipball/d217848e1396ef962fb1997cf3e2421acba7f796",
+                "reference": "d217848e1396ef962fb1997cf3e2421acba7f796",
                 "shasum": ""
             },
             "require": {
                 "parser",
                 "stylesheet"
             ],
-            "time": "2019-02-22T07:42:52+00:00"
+            "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.1",
+            "version": "v9.18.1.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/scrivo/highlight.php.git",
-                "reference": "52fc21c99fd888e33aed4879e55a3646f8d40558"
+                "reference": "05996fcc61e97978d76ca7d1ac14b65e7cd26f91"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/52fc21c99fd888e33aed4879e55a3646f8d40558",
-                "reference": "52fc21c99fd888e33aed4879e55a3646f8d40558",
+                "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/05996fcc61e97978d76ca7d1ac14b65e7cd26f91",
+                "reference": "05996fcc61e97978d76ca7d1ac14b65e7cd26f91",
                 "shasum": ""
             },
             "require": {
                 "symfony/finder": "^2.8|^3.4",
                 "symfony/var-dumper": "^2.8|^3.4"
             },
-            "suggest": {
-                "ext-dom": "Needed to make use of the features in the utilities namespace"
-            },
             "type": "library",
             "autoload": {
                 "psr-0": {
                 "highlight.php",
                 "syntax"
             ],
-            "time": "2020-03-02T05:59:21+00:00"
+            "support": {
+                "issues": "https://github.com/scrivo/highlight.php/issues",
+                "source": "https://github.com/scrivo/highlight.php"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/allejo",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-07-09T00:30:39+00:00"
         },
         {
             "name": "socialiteproviders/discord",
-            "version": "v2.0.2",
+            "version": "4.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Discord.git",
-                "reference": "e0cd8895f321943b36f533e7bf21ad29bcdece9a"
+                "reference": "c6eddeb07ace7473e82d02d4db852dfacf5ef574"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/e0cd8895f321943b36f533e7bf21ad29bcdece9a",
-                "reference": "e0cd8895f321943b36f533e7bf21ad29bcdece9a",
+                "url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c6eddeb07ace7473e82d02d4db852dfacf5ef574",
+                "reference": "c6eddeb07ace7473e82d02d4db852dfacf5ef574",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.6 || ^7.0",
-                "socialiteproviders/manager": "~2.0 || ~3.0"
+                "ext-json": "*",
+                "php": "^7.2 || ^8.0",
+                "socialiteproviders/manager": "~4.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "Discord OAuth2 Provider for Laravel Socialite",
-            "time": "2018-05-26T03:40:07+00:00"
+            "support": {
+                "source": "https://github.com/SocialiteProviders/Discord/tree/4.1.1"
+            },
+            "time": "2021-01-05T22:03:58+00:00"
         },
         {
             "name": "socialiteproviders/gitlab",
-            "version": "v3.1",
+            "version": "4.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/GitLab.git",
-                "reference": "69e537f6192ca15483e98b8662495384f44299ca"
+                "reference": "a8f67d3b02c9ee8c70c25c6728417c0eddcbbb9d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/GitLab/zipball/69e537f6192ca15483e98b8662495384f44299ca",
-                "reference": "69e537f6192ca15483e98b8662495384f44299ca",
+                "url": "https://api.github.com/repos/SocialiteProviders/GitLab/zipball/a8f67d3b02c9ee8c70c25c6728417c0eddcbbb9d",
+                "reference": "a8f67d3b02c9ee8c70c25c6728417c0eddcbbb9d",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.6 || ^7.0",
-                "socialiteproviders/manager": "~2.0 || ~3.0"
+                "ext-json": "*",
+                "php": "^7.2 || ^8.0",
+                "socialiteproviders/manager": "~4.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "GitLab OAuth2 Provider for Laravel Socialite",
-            "time": "2018-06-27T05:10:32+00:00"
+            "support": {
+                "source": "https://github.com/SocialiteProviders/GitLab/tree/4.1.0"
+            },
+            "time": "2020-12-01T23:10:59+00:00"
         },
         {
             "name": "socialiteproviders/manager",
-            "version": "v3.5",
+            "version": "4.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Manager.git",
-                "reference": "7a5872d9e4b22bb26ecd0c69ea9ddbaad8c0f570"
+                "reference": "0f5e82af0404df0080bdc5c105cef936c1711524"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/7a5872d9e4b22bb26ecd0c69ea9ddbaad8c0f570",
-                "reference": "7a5872d9e4b22bb26ecd0c69ea9ddbaad8c0f570",
+                "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/0f5e82af0404df0080bdc5c105cef936c1711524",
+                "reference": "0f5e82af0404df0080bdc5c105cef936c1711524",
                 "shasum": ""
             },
             "require": {
-                "illuminate/support": "~5.4|~5.7.0|~5.8.0|^6.0|^7.0",
-                "laravel/socialite": "~3.0|~4.0",
-                "php": "^5.6 || ^7.0"
+                "illuminate/support": "^6.0|^7.0|^8.0",
+                "laravel/socialite": "~4.0|~5.0",
+                "php": "^7.2 || ^8.0"
             },
             "require-dev": {
-                "mockery/mockery": "^0.9.4",
-                "phpunit/phpunit": "^5.0"
+                "mockery/mockery": "^1.2",
+                "phpunit/phpunit": "^9.0"
             },
             "type": "library",
             "extra": {
                 {
                     "name": "Miguel Piedrafita",
                     "email": "[email protected]"
+                },
+                {
+                    "name": "atymic",
+                    "email": "[email protected]",
+                    "homepage": "https://atymic.dev"
                 }
             ],
             "description": "Easily add new or override built-in providers in Laravel Socialite.",
-            "time": "2020-03-08T16:54:44+00:00"
+            "homepage": "https://socialiteproviders.com/",
+            "support": {
+                "issues": "https://github.com/SocialiteProviders/Manager/issues",
+                "source": "https://github.com/SocialiteProviders/Manager/tree/4.0.1"
+            },
+            "time": "2020-12-01T23:09:06+00:00"
         },
         {
             "name": "socialiteproviders/microsoft-azure",
-            "version": "v3.1.0",
+            "version": "4.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Microsoft-Azure.git",
-                "reference": "b22f4696cccecd6de902cf0bc923de7fc2e4608e"
+                "reference": "64779ec21db0bee3111039a67c0fa0ab550a3462"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/b22f4696cccecd6de902cf0bc923de7fc2e4608e",
-                "reference": "b22f4696cccecd6de902cf0bc923de7fc2e4608e",
+                "url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/64779ec21db0bee3111039a67c0fa0ab550a3462",
+                "reference": "64779ec21db0bee3111039a67c0fa0ab550a3462",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "php": "^5.6 || ^7.0",
-                "socialiteproviders/manager": "~2.0 || ~3.0"
+                "php": "^7.2 || ^8.0",
+                "socialiteproviders/manager": "~4.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "Microsoft Azure OAuth2 Provider for Laravel Socialite",
-            "time": "2020-04-30T23:01:40+00:00"
+            "support": {
+                "source": "https://github.com/SocialiteProviders/Microsoft-Azure/tree/4.2.1"
+            },
+            "time": "2021-06-14T22:51:38+00:00"
         },
         {
             "name": "socialiteproviders/okta",
-            "version": "v1.1.0",
+            "version": "4.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Okta.git",
-                "reference": "7c2512f0872316b139e3eea1c50c9351747a57ea"
+                "reference": "e3ef9f23c7d2f86b3b16a174b82333cf4e2459e8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Okta/zipball/7c2512f0872316b139e3eea1c50c9351747a57ea",
-                "reference": "7c2512f0872316b139e3eea1c50c9351747a57ea",
+                "url": "https://api.github.com/repos/SocialiteProviders/Okta/zipball/e3ef9f23c7d2f86b3b16a174b82333cf4e2459e8",
+                "reference": "e3ef9f23c7d2f86b3b16a174b82333cf4e2459e8",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "php": "^5.6 || ^7.0",
-                "socialiteproviders/manager": "~2.0 || ~3.0"
+                "php": "^7.2 || ^8.0",
+                "socialiteproviders/manager": "~4.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "Okta OAuth2 Provider for Laravel Socialite",
-            "time": "2019-09-06T15:27:03+00:00"
+            "support": {
+                "source": "https://github.com/SocialiteProviders/Okta/tree/4.1.1"
+            },
+            "time": "2021-01-12T23:51:01+00:00"
         },
         {
             "name": "socialiteproviders/slack",
-            "version": "v3.1",
+            "version": "4.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Slack.git",
-                "reference": "d46826640fbeae8f34328d99c358404a1e1050a3"
+                "reference": "2b781c95daf06ec87a8f3deba2ab613d6bea5e8d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Slack/zipball/d46826640fbeae8f34328d99c358404a1e1050a3",
-                "reference": "d46826640fbeae8f34328d99c358404a1e1050a3",
+                "url": "https://api.github.com/repos/SocialiteProviders/Slack/zipball/2b781c95daf06ec87a8f3deba2ab613d6bea5e8d",
+                "reference": "2b781c95daf06ec87a8f3deba2ab613d6bea5e8d",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.6 || ^7.0",
-                "socialiteproviders/manager": "~2.0 || ~3.0"
+                "ext-json": "*",
+                "php": "^7.2 || ^8.0",
+                "socialiteproviders/manager": "~4.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "Slack OAuth2 Provider for Laravel Socialite",
-            "time": "2019-01-11T19:48:14+00:00"
+            "support": {
+                "source": "https://github.com/SocialiteProviders/Slack/tree/4.1.1"
+            },
+            "time": "2021-03-26T04:10:10+00:00"
         },
         {
             "name": "socialiteproviders/twitch",
-            "version": "v5.2.0",
+            "version": "5.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SocialiteProviders/Twitch.git",
-                "reference": "9ee6fe196d7c28777139b3cde04cbd537cf7e652"
+                "reference": "7accf30ae7a3139b757b4ca8f34989c09a3dbee7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SocialiteProviders/Twitch/zipball/9ee6fe196d7c28777139b3cde04cbd537cf7e652",
-                "reference": "9ee6fe196d7c28777139b3cde04cbd537cf7e652",
+                "url": "https://api.github.com/repos/SocialiteProviders/Twitch/zipball/7accf30ae7a3139b757b4ca8f34989c09a3dbee7",
+                "reference": "7accf30ae7a3139b757b4ca8f34989c09a3dbee7",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
-                "php": "^5.6 || ^7.0",
-                "socialiteproviders/manager": "~2.0 || ~3.0"
+                "php": "^7.2 || ^8.0",
+                "socialiteproviders/manager": "~4.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "Twitch OAuth2 Provider for Laravel Socialite",
-            "time": "2020-05-06T22:51:30+00:00"
+            "support": {
+                "source": "https://github.com/SocialiteProviders/Twitch/tree/5.3.1"
+            },
+            "time": "2020-12-01T23:10:59+00:00"
         },
         {
-            "name": "steverhoades/oauth2-openid-connect-client",
-            "version": "v0.3.0",
+            "name": "ssddanbrown/htmldiff",
+            "version": "v1.0.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/steverhoades/oauth2-openid-connect-client.git",
-                "reference": "0159471487540a4620b8d0b693f5f215503a8d75"
+                "url": "https://github.com/ssddanbrown/HtmlDiff.git",
+                "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75",
+                "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/f60d5cc278b60305ab980a6665f46117c5b589c0",
+                "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=7.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5|^9.4.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Ssddanbrown\\HtmlDiff\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Dan Brown",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                }
+            ],
+            "description": "HTML Content Diff Generator",
+            "homepage": "https://github.com/ssddanbrown/htmldiff",
+            "support": {
+                "issues": "https://github.com/ssddanbrown/HtmlDiff/issues",
+                "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ssddanbrown",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-01-24T18:51:30+00:00"
+        },
+        {
+            "name": "steverhoades/oauth2-openid-connect-client",
+            "version": "v0.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/steverhoades/oauth2-openid-connect-client.git",
+                "reference": "0159471487540a4620b8d0b693f5f215503a8d75"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/steverhoades/oauth2-openid-connect-client/zipball/0159471487540a4620b8d0b693f5f215503a8d75",
                 "reference": "0159471487540a4620b8d0b693f5f215503a8d75",
                 "shasum": ""
             },
         {
             "name": "swiftmailer/swiftmailer",
             "version": "v6.2.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ssddanbrown/HtmlDiff.git",
+                "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/f60d5cc278b60305ab980a6665f46117c5b589c0",
+                "reference": "f60d5cc278b60305ab980a6665f46117c5b589c0",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=7.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5|^9.4.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Ssddanbrown\\HtmlDiff\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Dan Brown",
+                    "email": "[email protected]",
+                    "role": "Developer"
+                }
+            ],
+            "description": "HTML Content Diff Generator",
+            "homepage": "https://github.com/ssddanbrown/htmldiff",
+            "support": {
+                "issues": "https://github.com/ssddanbrown/HtmlDiff/issues",
+                "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ssddanbrown",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-01-24T18:51:30+00:00"
+        },
+        {
+            "name": "swiftmailer/swiftmailer",
+            "version": "v6.2.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/swiftmailer/swiftmailer.git",
-                "reference": "149cfdf118b169f7840bbe3ef0d4bc795d1780c9"
+                "reference": "15f7faf8508e04471f666633addacf54c0ab5933"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/149cfdf118b169f7840bbe3ef0d4bc795d1780c9",
-                "reference": "149cfdf118b169f7840bbe3ef0d4bc795d1780c9",
+                "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/15f7faf8508e04471f666633addacf54c0ab5933",
+                "reference": "15f7faf8508e04471f666633addacf54c0ab5933",
                 "shasum": ""
             },
             "require": {
-                "egulias/email-validator": "~2.0",
+                "egulias/email-validator": "^2.0|^3.1",
                 "php": ">=7.0.0",
                 "symfony/polyfill-iconv": "^1.0",
                 "symfony/polyfill-intl-idn": "^1.10",
                 "symfony/polyfill-mbstring": "^1.0"
             },
             "require-dev": {
-                "mockery/mockery": "~0.9.1",
-                "symfony/phpunit-bridge": "^3.4.19|^4.1.8"
+                "mockery/mockery": "^1.0",
+                "symfony/phpunit-bridge": "^4.4|^5.0"
             },
             "suggest": {
-                "ext-intl": "Needed to support internationalized email addresses",
-                "true/punycode": "Needed to support internationalized email addresses, if ext-intl is not installed"
+                "ext-intl": "Needed to support internationalized email addresses"
             },
             "type": "library",
             "extra": {
                 "mail",
                 "mailer"
             ],
-            "time": "2019-11-12T09:31:26+00:00"
+            "support": {
+                "issues": "https://github.com/swiftmailer/swiftmailer/issues",
+                "source": "https://github.com/swiftmailer/swiftmailer/tree/v6.2.7"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/swiftmailer/swiftmailer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-03-09T12:30:35+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v4.4.8",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "10bb3ee3c97308869d53b3e3d03f6ac23ff985f7"
+                "reference": "a3f7189a0665ee33b50e9e228c46f50f5acbed22"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/10bb3ee3c97308869d53b3e3d03f6ac23ff985f7",
-                "reference": "10bb3ee3c97308869d53b3e3d03f6ac23ff985f7",
+                "url": "https://api.github.com/repos/symfony/console/zipball/a3f7189a0665ee33b50e9e228c46f50f5acbed22",
+                "reference": "a3f7189a0665ee33b50e9e228c46f50f5acbed22",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
+                "php": ">=7.1.3",
                 "symfony/polyfill-mbstring": "~1.0",
                 "symfony/polyfill-php73": "^1.8",
+                "symfony/polyfill-php80": "^1.16",
                 "symfony/service-contracts": "^1.1|^2"
             },
             "conflict": {
+                "psr/log": ">=3",
                 "symfony/dependency-injection": "<3.4",
                 "symfony/event-dispatcher": "<4.3|>=5",
                 "symfony/lock": "<4.4",
                 "symfony/process": "<3.3"
             },
             "provide": {
-                "psr/log-implementation": "1.0"
+                "psr/log-implementation": "1.0|2.0"
             },
             "require-dev": {
-                "psr/log": "~1.0",
+                "psr/log": "^1|^2",
                 "symfony/config": "^3.4|^4.0|^5.0",
                 "symfony/dependency-injection": "^3.4|^4.0|^5.0",
                 "symfony/event-dispatcher": "^4.3",
                 "symfony/process": ""
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\Console\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Console Component",
+            "description": "Eases the creation of beautiful and testable command line interfaces",
             "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/console/tree/v4.4.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-30T11:41:10+00:00"
+            "time": "2021-08-25T19:27:26+00:00"
         },
         {
             "name": "symfony/css-selector",
-            "version": "v4.4.8",
+            "version": "v5.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/css-selector.git",
-                "reference": "afc26133a6fbdd4f8842e38893e0ee4685c7c94b"
+                "reference": "7fb120adc7f600a59027775b224c13a33530dd90"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/css-selector/zipball/afc26133a6fbdd4f8842e38893e0ee4685c7c94b",
-                "reference": "afc26133a6fbdd4f8842e38893e0ee4685c7c94b",
+                "url": "https://api.github.com/repos/symfony/css-selector/zipball/7fb120adc7f600a59027775b224c13a33530dd90",
+                "reference": "7fb120adc7f600a59027775b224c13a33530dd90",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3"
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\CssSelector\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony CssSelector Component",
+            "description": "Converts CSS selectors to XPath expressions",
             "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/css-selector/tree/v5.3.4"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-27T16:54:36+00:00"
+            "time": "2021-07-21T12:38:00+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v4.4.8",
+            "version": "v4.4.27",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "346636d2cae417992ecfd761979b2ab98b339a45"
+                "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/346636d2cae417992ecfd761979b2ab98b339a45",
-                "reference": "346636d2cae417992ecfd761979b2ab98b339a45",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/2f9160e92eb64c95da7368c867b663a8e34e980c",
+                "reference": "2f9160e92eb64c95da7368c867b663a8e34e980c",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "psr/log": "~1.0"
+                "php": ">=7.1.3",
+                "psr/log": "^1|^2|^3"
             },
             "conflict": {
                 "symfony/http-kernel": "<3.4"
                 "symfony/http-kernel": "^3.4|^4.0|^5.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\Debug\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Debug Component",
+            "description": "Provides tools to ease debugging PHP code",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/debug/tree/v4.4.27"
+            },
+            "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-07-22T07:21:39+00:00"
+        },
+        {
+            "name": "symfony/deprecation-contracts",
+            "version": "v2.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/deprecation-contracts.git",
+                "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627",
+                "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.4-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "function.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": "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"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-27T16:54:36+00:00"
+            "time": "2021-03-23T23:28:01+00:00"
         },
         {
             "name": "symfony/error-handler",
-            "version": "v4.4.8",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/error-handler.git",
-                "reference": "7e9828fc98aa1cf27b422fe478a84f5b0abb7358"
+                "reference": "51f98f7aa99f00f3b1da6bafe934e67ae6ba6dc5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/error-handler/zipball/7e9828fc98aa1cf27b422fe478a84f5b0abb7358",
-                "reference": "7e9828fc98aa1cf27b422fe478a84f5b0abb7358",
+                "url": "https://api.github.com/repos/symfony/error-handler/zipball/51f98f7aa99f00f3b1da6bafe934e67ae6ba6dc5",
+                "reference": "51f98f7aa99f00f3b1da6bafe934e67ae6ba6dc5",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "psr/log": "~1.0",
+                "php": ">=7.1.3",
+                "psr/log": "^1|^2|^3",
                 "symfony/debug": "^4.4.5",
                 "symfony/var-dumper": "^4.4|^5.0"
             },
                 "symfony/serializer": "^4.4|^5.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\ErrorHandler\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony ErrorHandler Component",
+            "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.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-30T14:07:33+00:00"
+            "time": "2021-08-27T17:42:48+00:00"
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v4.4.8",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "abc8e3618bfdb55e44c8c6a00abd333f831bbfed"
+                "reference": "2fe81680070043c4c80e7cedceb797e34f377bac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abc8e3618bfdb55e44c8c6a00abd333f831bbfed",
-                "reference": "abc8e3618bfdb55e44c8c6a00abd333f831bbfed",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2fe81680070043c4c80e7cedceb797e34f377bac",
+                "reference": "2fe81680070043c4c80e7cedceb797e34f377bac",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "symfony/event-dispatcher-contracts": "^1.1"
+                "php": ">=7.1.3",
+                "symfony/event-dispatcher-contracts": "^1.1",
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
                 "symfony/dependency-injection": "<3.4"
                 "symfony/event-dispatcher-implementation": "1.1"
             },
             "require-dev": {
-                "psr/log": "~1.0",
+                "psr/log": "^1|^2|^3",
                 "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/http-kernel": ""
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\EventDispatcher\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony EventDispatcher Component",
+            "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.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-27T16:54:36+00:00"
+            "time": "2021-08-04T20:31:23+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
-            "version": "v1.1.7",
+            "version": "v1.1.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher-contracts.git",
-                "reference": "c43ab685673fb6c8d84220c77897b1d6cdbe1d18"
+                "reference": "84e23fdcd2517bf37aecbd16967e83f0caee25a7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c43ab685673fb6c8d84220c77897b1d6cdbe1d18",
-                "reference": "c43ab685673fb6c8d84220c77897b1d6cdbe1d18",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/84e23fdcd2517bf37aecbd16967e83f0caee25a7",
+                "reference": "84e23fdcd2517bf37aecbd16967e83f0caee25a7",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3"
+                "php": ">=7.1.3"
             },
             "suggest": {
                 "psr/event-dispatcher": "",
             "extra": {
                 "branch-alias": {
                     "dev-master": "1.1-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
                 }
             },
             "autoload": {
                 "interoperability",
                 "standards"
             ],
-            "time": "2019-09-17T09:54:03+00:00"
+            "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.8",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "5729f943f9854c5781984ed4907bbb817735776b"
+                "reference": "70362f1e112280d75b30087c7598b837c1b468b6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/5729f943f9854c5781984ed4907bbb817735776b",
-                "reference": "5729f943f9854c5781984ed4907bbb817735776b",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/70362f1e112280d75b30087c7598b837c1b468b6",
+                "reference": "70362f1e112280d75b30087c7598b837c1b468b6",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3"
+                "php": ">=7.1.3",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\Finder\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Finder Component",
+            "description": "Finds files and directories via an intuitive fluent interface",
             "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/finder/tree/v4.4.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-27T16:54:36+00:00"
+            "time": "2021-08-04T20:31:23+00:00"
         },
         {
-            "name": "symfony/http-foundation",
-            "version": "v4.4.8",
+            "name": "symfony/http-client-contracts",
+            "version": "v2.4.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "ec5bd254c223786f5fa2bb49a1e705c1b8e7cee2"
+                "url": "https://github.com/symfony/http-client-contracts.git",
+                "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ec5bd254c223786f5fa2bb49a1e705c1b8e7cee2",
-                "reference": "ec5bd254c223786f5fa2bb49a1e705c1b8e7cee2",
+                "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/7e82f6084d7cae521a75ef2cb5c9457bbda785f4",
+                "reference": "7e82f6084d7cae521a75ef2cb5c9457bbda785f4",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "symfony/mime": "^4.3|^5.0",
-                "symfony/polyfill-mbstring": "~1.1"
+                "php": ">=7.2.5"
             },
-            "require-dev": {
-                "predis/predis": "~1.0",
-                "symfony/expression-language": "^3.4|^4.0|^5.0"
+            "suggest": {
+                "symfony/http-client-implementation": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "4.4-dev"
+                    "dev-main": "2.4-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Component\\HttpFoundation\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
+                    "Symfony\\Contracts\\HttpClient\\": ""
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
             ],
             "authors": [
                 {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony HttpFoundation Component",
+            "description": "Generic abstractions related to HTTP clients",
             "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/http-client-contracts/tree/v2.4.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-18T20:40:08+00:00"
+            "time": "2021-04-11T23:07:08+00:00"
         },
         {
-            "name": "symfony/http-kernel",
-            "version": "v4.4.8",
+            "name": "symfony/http-foundation",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "1799a6c01f0db5851f399151abdb5d6393fec277"
+                "url": "https://github.com/symfony/http-foundation.git",
+                "reference": "09b3202651ab23ac8dcf455284a48a3500e56731"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1799a6c01f0db5851f399151abdb5d6393fec277",
-                "reference": "1799a6c01f0db5851f399151abdb5d6393fec277",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/09b3202651ab23ac8dcf455284a48a3500e56731",
+                "reference": "09b3202651ab23ac8dcf455284a48a3500e56731",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "psr/log": "~1.0",
-                "symfony/error-handler": "^4.4",
-                "symfony/event-dispatcher": "^4.4",
-                "symfony/http-foundation": "^4.4|^5.0",
-                "symfony/polyfill-ctype": "^1.8",
-                "symfony/polyfill-php73": "^1.9"
-            },
-            "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.34|<2.4,>=2"
-            },
-            "provide": {
-                "psr/log-implementation": "1.0"
+                "php": ">=7.1.3",
+                "symfony/mime": "^4.3|^5.0",
+                "symfony/polyfill-mbstring": "~1.1",
+                "symfony/polyfill-php80": "^1.16"
             },
             "require-dev": {
-                "psr/cache": "~1.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.34|^2.4|^3.0"
-            },
-            "suggest": {
-                "symfony/browser-kit": "",
-                "symfony/config": "",
-                "symfony/console": "",
-                "symfony/dependency-injection": ""
+                "predis/predis": "~1.0",
+                "symfony/expression-language": "^3.4|^4.0|^5.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\HttpFoundation\\": ""
+                },
+                "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": "Defines an object-oriented layer for the HTTP specification",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/http-foundation/tree/v4.4.30"
+            },
+            "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-08-26T15:51:23+00:00"
+        },
+        {
+            "name": "symfony/http-kernel",
+            "version": "v4.4.30",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/http-kernel.git",
+                "reference": "87f7ea4a8a7a30c967e26001de99f12943bf57ae"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/87f7ea4a8a7a30c967e26001de99f12943bf57ae",
+                "reference": "87f7ea4a8a7a30c967e26001de99f12943bf57ae",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1.3",
+                "psr/log": "^1|^2",
+                "symfony/error-handler": "^4.4",
+                "symfony/event-dispatcher": "^4.4",
+                "symfony/http-client-contracts": "^1.1|^2",
+                "symfony/http-foundation": "^4.4.30|^5.3.7",
+                "symfony/polyfill-ctype": "^1.8",
+                "symfony/polyfill-php73": "^1.9",
+                "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"
             },
+            "provide": {
+                "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"
+            },
+            "suggest": {
+                "symfony/browser-kit": "",
+                "symfony/config": "",
+                "symfony/console": "",
+                "symfony/dependency-injection": ""
+            },
+            "type": "library",
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\HttpKernel\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony HttpKernel Component",
+            "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.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-28T18:47:42+00:00"
+            "time": "2021-08-30T12:27:20+00:00"
         },
         {
             "name": "symfony/mime",
-            "version": "v4.4.8",
+            "version": "v5.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/mime.git",
-                "reference": "7a583ffb6c7dd5aabb5db920817a3cc39261c517"
+                "reference": "ae887cb3b044658676129f5e97aeb7e9eb69c2d8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/mime/zipball/7a583ffb6c7dd5aabb5db920817a3cc39261c517",
-                "reference": "7a583ffb6c7dd5aabb5db920817a3cc39261c517",
+                "url": "https://api.github.com/repos/symfony/mime/zipball/ae887cb3b044658676129f5e97aeb7e9eb69c2d8",
+                "reference": "ae887cb3b044658676129f5e97aeb7e9eb69c2d8",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1",
                 "symfony/polyfill-intl-idn": "^1.10",
-                "symfony/polyfill-mbstring": "^1.0"
+                "symfony/polyfill-mbstring": "^1.0",
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
+                "egulias/email-validator": "~3.0.0",
+                "phpdocumentor/reflection-docblock": "<3.2.2",
+                "phpdocumentor/type-resolver": "<1.4.0",
                 "symfony/mailer": "<4.4"
             },
             "require-dev": {
-                "egulias/email-validator": "^2.1.10",
-                "symfony/dependency-injection": "^3.4|^4.1|^5.0"
+                "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"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\Mime\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "A library to manipulate MIME messages",
+            "description": "Allows manipulating MIME messages",
             "homepage": "https://symfony.com",
             "keywords": [
                 "mime",
                 "mime-type"
             ],
+            "support": {
+                "source": "https://github.com/symfony/mime/tree/v5.3.7"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-16T14:49:30+00:00"
+            "time": "2021-08-20T11:40:01+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.17.0",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9"
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
-                "reference": "e94c8b1bbe2bc77507a1056cdb06451c75b427f9",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
             },
             "suggest": {
                 "ext-ctype": "For best performance"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.17-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "polyfill",
                 "portable"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-12T16:14:59+00:00"
+            "time": "2021-02-19T12:13:01+00:00"
         },
         {
             "name": "symfony/polyfill-iconv",
-            "version": "v1.17.0",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-iconv.git",
-                "reference": "c4de7601eefbf25f9d47190abe07f79fe0a27424"
+                "reference": "63b5bb7db83e5673936d6e3b8b3e022ff6474933"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/c4de7601eefbf25f9d47190abe07f79fe0a27424",
-                "reference": "c4de7601eefbf25f9d47190abe07f79fe0a27424",
+                "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/63b5bb7db83e5673936d6e3b8b3e022ff6474933",
+                "reference": "63b5bb7db83e5673936d6e3b8b3e022ff6474933",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
             },
             "suggest": {
                 "ext-iconv": "For best performance"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.17-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "portable",
                 "shim"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-iconv/tree/v1.23.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-12T16:47:27+00:00"
+            "time": "2021-05-27T09:27:20+00:00"
         },
         {
             "name": "symfony/polyfill-intl-idn",
-            "version": "v1.17.0",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-intl-idn.git",
-                "reference": "3bff59ea7047e925be6b7f2059d60af31bb46d6a"
+                "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3bff59ea7047e925be6b7f2059d60af31bb46d6a",
-                "reference": "3bff59ea7047e925be6b7f2059d60af31bb46d6a",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/65bd267525e82759e7d8c4e8ceea44f398838e65",
+                "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3",
-                "symfony/polyfill-mbstring": "^1.3",
+                "php": ">=7.1",
+                "symfony/polyfill-intl-normalizer": "^1.10",
                 "symfony/polyfill-php72": "^1.10"
             },
             "suggest": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.17-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                     "name": "Laurent Bassin",
                     "email": "[email protected]"
                 },
+                {
+                    "name": "Trevor Rowbotham",
+                    "email": "[email protected]"
+                },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 "portable",
                 "shim"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.23.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-12T16:47:27+00:00"
+            "time": "2021-05-27T09:27:20+00:00"
         },
         {
-            "name": "symfony/polyfill-mbstring",
-            "version": "v1.17.0",
+            "name": "symfony/polyfill-intl-normalizer",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "fa79b11539418b02fc5e1897267673ba2c19419c"
+                "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+                "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fa79b11539418b02fc5e1897267673ba2c19419c",
-                "reference": "fa79b11539418b02fc5e1897267673ba2c19419c",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
+                "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
             },
             "suggest": {
-                "ext-mbstring": "For best performance"
+                "ext-intl": "For best performance"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.17-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Polyfill\\Mbstring\\": ""
+                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
                 },
                 "files": [
                     "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony polyfill for the Mbstring extension",
+            "description": "Symfony polyfill for intl's Normalizer class and related functions",
             "homepage": "https://symfony.com",
             "keywords": [
                 "compatibility",
-                "mbstring",
+                "intl",
+                "normalizer",
                 "polyfill",
                 "portable",
                 "shim"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-12T16:47:27+00:00"
+            "time": "2021-02-19T12:13:01+00:00"
         },
         {
-            "name": "symfony/polyfill-php72",
-            "version": "v1.17.0",
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.23.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/polyfill-php72.git",
-                "reference": "f048e612a3905f34931127360bdd2def19a5e582"
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/f048e612a3905f34931127360bdd2def19a5e582",
-                "reference": "f048e612a3905f34931127360bdd2def19a5e582",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9174a3d80210dca8daa7f31fec659150bbeabfc6",
+                "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.17-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Polyfill\\Php72\\": ""
+                    "Symfony\\Polyfill\\Mbstring\\": ""
                 },
                 "files": [
                     "bootstrap.php"
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions",
+            "description": "Symfony polyfill for the Mbstring extension",
             "homepage": "https://symfony.com",
             "keywords": [
                 "compatibility",
+                "mbstring",
                 "polyfill",
                 "portable",
                 "shim"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.1"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-12T16:47:27+00:00"
+            "time": "2021-05-27T12:26:48+00:00"
         },
         {
-            "name": "symfony/polyfill-php73",
-            "version": "v1.17.0",
+            "name": "symfony/polyfill-php72",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/polyfill-php73.git",
-                "reference": "a760d8964ff79ab9bf057613a5808284ec852ccc"
+                "url": "https://github.com/symfony/polyfill-php72.git",
+                "reference": "9a142215a36a3888e30d0a9eeea9766764e96976"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/a760d8964ff79ab9bf057613a5808284ec852ccc",
-                "reference": "a760d8964ff79ab9bf057613a5808284ec852ccc",
+                "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976",
+                "reference": "9a142215a36a3888e30d0a9eeea9766764e96976",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.17-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Polyfill\\Php73\\": ""
+                    "Symfony\\Polyfill\\Php72\\": ""
                 },
                 "files": [
                     "bootstrap.php"
-                ],
-                "classmap": [
-                    "Resources/stubs"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+            "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions",
             "homepage": "https://symfony.com",
             "keywords": [
                 "compatibility",
                 "portable",
                 "shim"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php72/tree/v1.23.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-12T16:47:27+00:00"
+            "time": "2021-05-27T09:17:38+00:00"
         },
         {
-            "name": "symfony/process",
-            "version": "v4.4.8",
+            "name": "symfony/polyfill-php73",
+            "version": "v1.23.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/process.git",
-                "reference": "4b6a9a4013baa65d409153cbb5a895bf093dc7f4"
+                "url": "https://github.com/symfony/polyfill-php73.git",
+                "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/4b6a9a4013baa65d409153cbb5a895bf093dc7f4",
-                "reference": "4b6a9a4013baa65d409153cbb5a895bf093dc7f4",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010",
+                "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3"
+                "php": ">=7.1"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "4.4-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Component\\Process\\": ""
+                    "Symfony\\Polyfill\\Php73\\": ""
                 },
-                "exclude-from-classmap": [
-                    "/Tests/"
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
             ],
             "authors": [
                 {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Process Component",
+            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
             "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-15T15:56:18+00:00"
+            "time": "2021-02-19T12:13:01+00:00"
         },
         {
-            "name": "symfony/routing",
-            "version": "v4.4.8",
+            "name": "symfony/polyfill-php80",
+            "version": "v1.23.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/routing.git",
-                "reference": "67b4e1f99c050cbc310b8f3d0dbdc4b0212c052c"
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/routing/zipball/67b4e1f99c050cbc310b8f3d0dbdc4b0212c052c",
-                "reference": "67b4e1f99c050cbc310b8f3d0dbdc4b0212c052c",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/1100343ed1a92e3a38f9ae122fc0eb21602547be",
+                "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3"
-            },
-            "conflict": {
-                "symfony/config": "<4.2",
-                "symfony/dependency-injection": "<3.4",
-                "symfony/yaml": "<3.4"
-            },
-            "require-dev": {
-                "doctrine/annotations": "~1.2",
-                "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"
-            },
-            "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",
-                "symfony/yaml": "For using the YAML loader"
+                "php": ">=7.1"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "4.4-dev"
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Component\\Routing\\": ""
+                    "Symfony\\Polyfill\\Php80\\": ""
                 },
-                "exclude-from-classmap": [
-                    "/Tests/"
+                "files": [
+                    "bootstrap.php"
+                ],
+                "classmap": [
+                    "Resources/stubs"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
             ],
             "authors": [
                 {
-                    "name": "Fabien Potencier",
-                    "email": "[email protected]"
+                    "name": "Ion Bazan",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "[email protected]"
                 },
                 {
                     "name": "Symfony Community",
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Routing Component",
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
             "homepage": "https://symfony.com",
             "keywords": [
-                "router",
-                "routing",
-                "uri",
-                "url"
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
             ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.1"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-21T19:59:53+00:00"
+            "time": "2021-07-28T13:41:28+00:00"
         },
         {
-            "name": "symfony/service-contracts",
-            "version": "v1.1.8",
+            "name": "symfony/process",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
-                "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf"
+                "url": "https://github.com/symfony/process.git",
+                "reference": "13d3161ef63a8ec21eeccaaf9a4d7f784a87a97d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
-                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
+                "url": "https://api.github.com/repos/symfony/process/zipball/13d3161ef63a8ec21eeccaaf9a4d7f784a87a97d",
+                "reference": "13d3161ef63a8ec21eeccaaf9a4d7f784a87a97d",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "psr/container": "^1.0"
-            },
-            "suggest": {
-                "symfony/service-implementation": ""
+                "php": ">=7.1.3",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.1-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
-                    "Symfony\\Contracts\\Service\\": ""
-                }
+                    "Symfony\\Component\\Process\\": ""
+                },
+                "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": "Executes commands in sub-processes",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/process/tree/v4.4.30"
+            },
+            "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-08-04T20:31:23+00:00"
+        },
+        {
+            "name": "symfony/routing",
+            "version": "v4.4.30",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/routing.git",
+                "reference": "9ddf033927ad9f30ba2bfd167a7b342cafa13e8e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/9ddf033927ad9f30ba2bfd167a7b342cafa13e8e",
+                "reference": "9ddf033927ad9f30ba2bfd167a7b342cafa13e8e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1.3",
+                "symfony/polyfill-php80": "^1.16"
+            },
+            "conflict": {
+                "symfony/config": "<4.2",
+                "symfony/dependency-injection": "<3.4",
+                "symfony/yaml": "<3.4"
+            },
+            "require-dev": {
+                "doctrine/annotations": "^1.10.4",
+                "psr/log": "^1|^2|^3",
+                "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"
+            },
+            "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",
+                "symfony/yaml": "For using the YAML loader"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Routing\\": ""
+                },
+                "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": "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/v4.4.30"
+            },
+            "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-08-04T21:41:01+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v2.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
+                "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/container": "^1.1"
+            },
+            "suggest": {
+                "symfony/service-implementation": ""
+            },
+            "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\\Service\\": ""
+                }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "interoperability",
                 "standards"
             ],
-            "time": "2019-10-14T12:27:06+00:00"
+            "support": {
+                "source": "https://github.com/symfony/service-contracts/tree/v2.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-04-01T10:43:52+00:00"
         },
         {
             "name": "symfony/translation",
-            "version": "v4.4.8",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation.git",
-                "reference": "8272bbd2b7e220ef812eba2a2b30068a5c64b191"
+                "reference": "db0ba1e85280d8ff11e38d53c70f8814d4d740f5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation/zipball/8272bbd2b7e220ef812eba2a2b30068a5c64b191",
-                "reference": "8272bbd2b7e220ef812eba2a2b30068a5c64b191",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/db0ba1e85280d8ff11e38d53c70f8814d4d740f5",
+                "reference": "db0ba1e85280d8ff11e38d53c70f8814d4d740f5",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
+                "php": ">=7.1.3",
                 "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php80": "^1.16",
                 "symfony/translation-contracts": "^1.1.6|^2"
             },
             "conflict": {
                 "symfony/yaml": "<3.4"
             },
             "provide": {
-                "symfony/translation-implementation": "1.0"
+                "symfony/translation-implementation": "1.0|2.0"
             },
             "require-dev": {
-                "psr/log": "~1.0",
+                "psr/log": "^1|^2|^3",
                 "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/yaml": ""
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\Translation\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Translation Component",
+            "description": "Provides tools to internationalize your application",
             "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/translation/tree/v4.4.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-12T16:45:36+00:00"
+            "time": "2021-08-26T05:57:13+00:00"
         },
         {
             "name": "symfony/translation-contracts",
-            "version": "v1.1.7",
+            "version": "v2.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/translation-contracts.git",
-                "reference": "364518c132c95642e530d9b2d217acbc2ccac3e6"
+                "reference": "95c812666f3e91db75385749fe219c5e494c7f95"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/364518c132c95642e530d9b2d217acbc2ccac3e6",
-                "reference": "364518c132c95642e530d9b2d217acbc2ccac3e6",
+                "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/95c812666f3e91db75385749fe219c5e494c7f95",
+                "reference": "95c812666f3e91db75385749fe219c5e494c7f95",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3"
+                "php": ">=7.2.5"
             },
             "suggest": {
                 "symfony/translation-implementation": ""
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.1-dev"
+                    "dev-main": "2.4-dev"
+                },
+                "thanks": {
+                    "name": "symfony/contracts",
+                    "url": "https://github.com/symfony/contracts"
                 }
             },
             "autoload": {
                 "interoperability",
                 "standards"
             ],
-            "time": "2019-09-17T11:12:18+00:00"
+            "support": {
+                "source": "https://github.com/symfony/translation-contracts/tree/v2.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-03-23T23:28:01+00:00"
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v4.4.8",
+            "version": "v4.4.30",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "c587e04ce5d1aa62d534a038f574d9a709e814cf"
+                "reference": "7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c587e04ce5d1aa62d534a038f574d9a709e814cf",
-                "reference": "c587e04ce5d1aa62d534a038f574d9a709e814cf",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c",
+                "reference": "7f65c44c2ce80d3a0fcdb6385ee0ad535e45660c",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
+                "php": ">=7.1.3",
                 "symfony/polyfill-mbstring": "~1.0",
-                "symfony/polyfill-php72": "~1.5"
+                "symfony/polyfill-php72": "~1.5",
+                "symfony/polyfill-php80": "^1.16"
             },
             "conflict": {
                 "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0",
                 "ext-iconv": "*",
                 "symfony/console": "^3.4|^4.0|^5.0",
                 "symfony/process": "^4.4|^5.0",
-                "twig/twig": "^1.34|^2.4|^3.0"
+                "twig/twig": "^1.43|^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).",
                 "Resources/bin/var-dump-server"
             ],
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "files": [
                     "Resources/functions/dump.php"
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony mechanism for exploring and dumping PHP variables",
+            "description": "Provides mechanisms for walking through any arbitrary PHP variable",
             "homepage": "https://symfony.com",
             "keywords": [
                 "debug",
                 "dump"
             ],
+            "support": {
+                "source": "https://github.com/symfony/var-dumper/tree/v4.4.30"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-12T16:14:02+00:00"
+            "time": "2021-08-04T20:31:23+00:00"
         },
         {
             "name": "tijsverkoyen/css-to-inline-styles",
-            "version": "2.2.2",
+            "version": "2.2.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
-                "reference": "dda2ee426acd6d801d5b7fd1001cde9b5f790e15"
+                "reference": "b43b05cf43c1b6d849478965062b6ef73e223bb5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/dda2ee426acd6d801d5b7fd1001cde9b5f790e15",
-                "reference": "dda2ee426acd6d801d5b7fd1001cde9b5f790e15",
+                "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/b43b05cf43c1b6d849478965062b6ef73e223bb5",
+                "reference": "b43b05cf43c1b6d849478965062b6ef73e223bb5",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-libxml": "*",
-                "php": "^5.5 || ^7.0",
+                "php": "^5.5 || ^7.0 || ^8.0",
                 "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0"
+                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5"
             },
             "type": "library",
             "extra": {
             ],
             "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
             "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
-            "time": "2019-10-24T08:53:34+00:00"
+            "support": {
+                "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
+                "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/2.2.3"
+            },
+            "time": "2020-07-13T06:12:54+00:00"
         },
         {
             "name": "vlucas/phpdotenv",
-            "version": "v3.6.4",
+            "version": "v3.6.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/vlucas/phpdotenv.git",
-                "reference": "10d3f853fdf1f3a6b3c7ea0c4620d2f699713db5"
+                "reference": "5e679f7616db829358341e2d5cccbd18773bdab8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/10d3f853fdf1f3a6b3c7ea0c4620d2f699713db5",
-                "reference": "10d3f853fdf1f3a6b3c7ea0c4620d2f699713db5",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/5e679f7616db829358341e2d5cccbd18773bdab8",
+                "reference": "5e679f7616db829358341e2d5cccbd18773bdab8",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.4 || ^7.0 || ^8.0",
-                "phpoption/phpoption": "^1.5",
-                "symfony/polyfill-ctype": "^1.9"
+                "phpoption/phpoption": "^1.5.2",
+                "symfony/polyfill-ctype": "^1.17"
             },
             "require-dev": {
                 "ext-filter": "*",
                 "ext-pcre": "*",
-                "phpunit/phpunit": "^4.8.35 || ^5.0 || ^6.0 || ^7.0"
+                "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20"
             },
             "suggest": {
                 "ext-filter": "Required to use the boolean validator.",
                 "env",
                 "environment"
             ],
+            "support": {
+                "issues": "https://github.com/vlucas/phpdotenv/issues",
+                "source": "https://github.com/vlucas/phpdotenv/tree/v3.6.8"
+            },
             "funding": [
                 {
                     "url": "https://github.com/GrahamCampbell",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-05-02T13:46:13+00:00"
+            "time": "2021-01-20T14:39:46+00:00"
         }
     ],
     "packages-dev": [
         {
             "name": "barryvdh/laravel-debugbar",
-            "version": "v3.3.3",
+            "version": "v3.6.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/barryvdh/laravel-debugbar.git",
-                "reference": "57f2219f6d9efe41ed1bc880d86701c52f261bf5"
+                "reference": "70b89754913fd89fef16d0170a91dbc2a5cd633a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/57f2219f6d9efe41ed1bc880d86701c52f261bf5",
-                "reference": "57f2219f6d9efe41ed1bc880d86701c52f261bf5",
+                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/70b89754913fd89fef16d0170a91dbc2a5cd633a",
+                "reference": "70b89754913fd89fef16d0170a91dbc2a5cd633a",
                 "shasum": ""
             },
             "require": {
-                "illuminate/routing": "^5.5|^6|^7",
-                "illuminate/session": "^5.5|^6|^7",
-                "illuminate/support": "^5.5|^6|^7",
-                "maximebf/debugbar": "^1.15.1",
-                "php": ">=7.0",
-                "symfony/debug": "^3|^4|^5",
-                "symfony/finder": "^3|^4|^5"
+                "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"
             },
             "require-dev": {
-                "laravel/framework": "5.5.x"
+                "mockery/mockery": "^1.3.3",
+                "orchestra/testbench-dusk": "^4|^5|^6",
+                "phpunit/phpunit": "^8.5|^9.0",
+                "squizlabs/php_codesniffer": "^3.5"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.2-dev"
+                    "dev-master": "3.5-dev"
                 },
                 "laravel": {
                     "providers": [
                 "profiler",
                 "webprofiler"
             ],
+            "support": {
+                "issues": "https://github.com/barryvdh/laravel-debugbar/issues",
+                "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.6.2"
+            },
             "funding": [
+                {
+                    "url": "https://fruitcake.nl",
+                    "type": "custom"
+                },
                 {
                     "url": "https://github.com/barryvdh",
                     "type": "github"
                 }
             ],
-            "time": "2020-05-05T10:53:32+00:00"
+            "time": "2021-06-14T14:29:26+00:00"
         },
         {
             "name": "barryvdh/laravel-ide-helper",
-            "version": "v2.7.0",
+            "version": "v2.8.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/barryvdh/laravel-ide-helper.git",
-                "reference": "5f677edc14bdcfdcac36633e6eea71b2728a4dbc"
+                "reference": "5515cabea39b9cf55f98980d0f269dc9d85cfcca"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/5f677edc14bdcfdcac36633e6eea71b2728a4dbc",
-                "reference": "5f677edc14bdcfdcac36633e6eea71b2728a4dbc",
+                "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",
+                "composer/composer": "^1.6 || ^2",
                 "doctrine/dbal": "~2.3",
-                "illuminate/console": "^5.5|^6|^7",
-                "illuminate/filesystem": "^5.5|^6|^7",
-                "illuminate/support": "^5.5|^6|^7",
-                "php": ">=7.2"
+                "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": {
-                "illuminate/config": "^5.5|^6|^7",
-                "illuminate/view": "^5.5|^6|^7",
-                "mockery/mockery": "^1.3",
-                "orchestra/testbench": "^3|^4|^5",
-                "phpro/grumphp": "^0.17.1",
-                "squizlabs/php_codesniffer": "^3"
+                "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.6-dev"
+                    "dev-master": "2.8-dev"
                 },
                 "laravel": {
                     "providers": [
                 "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://github.com/barryvdh",
                     "type": "github"
                 }
             ],
-            "time": "2020-04-22T09:57:26+00:00"
+            "time": "2020-12-06T08:55:05+00:00"
         },
         {
             "name": "barryvdh/reflection-docblock",
                     "email": "[email protected]"
                 }
             ],
+            "support": {
+                "source": "https://github.com/barryvdh/ReflectionDocBlock/tree/v2.0.6"
+            },
             "time": "2018-12-13T10:34:14+00:00"
         },
         {
             "name": "composer/ca-bundle",
-            "version": "1.2.7",
+            "version": "1.2.10",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/ca-bundle.git",
-                "reference": "95c63ab2117a72f48f5a55da9740a3273d45b7fd"
+                "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/95c63ab2117a72f48f5a55da9740a3273d45b7fd",
-                "reference": "95c63ab2117a72f48f5a55da9740a3273d45b7fd",
+                "url": "https://api.github.com/repos/composer/ca-bundle/zipball/9fdb22c2e97a614657716178093cd1da90a64aa8",
+                "reference": "9fdb22c2e97a614657716178093cd1da90a64aa8",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.3.2 || ^7.0 || ^8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8",
+                "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"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.x-dev"
+                    "dev-main": "1.x-dev"
                 }
             },
             "autoload": {
                 "ssl",
                 "tls"
             ],
+            "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.10"
+            },
             "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": "2020-04-08T08:27:21+00:00"
+            "time": "2021-06-07T13:58:28+00:00"
         },
         {
             "name": "composer/composer",
-            "version": "1.10.6",
+            "version": "2.1.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/composer.git",
-                "reference": "be81b9c4735362c26876bdbfd3b5bc7e7f711c88"
+                "reference": "24d38e9686092de05214cafa187dc282a5d89497"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/composer/zipball/be81b9c4735362c26876bdbfd3b5bc7e7f711c88",
-                "reference": "be81b9c4735362c26876bdbfd3b5bc7e7f711c88",
+                "url": "https://api.github.com/repos/composer/composer/zipball/24d38e9686092de05214cafa187dc282a5d89497",
+                "reference": "24d38e9686092de05214cafa187dc282a5d89497",
                 "shasum": ""
             },
             "require": {
                 "composer/ca-bundle": "^1.0",
-                "composer/semver": "^1.0",
+                "composer/metadata-minifier": "^1.0",
+                "composer/semver": "^3.0",
                 "composer/spdx-licenses": "^1.2",
-                "composer/xdebug-handler": "^1.1",
-                "justinrainbow/json-schema": "^3.0 || ^4.0 || ^5.0",
-                "php": "^5.3.2 || ^7.0",
+                "composer/xdebug-handler": "^2.0",
+                "justinrainbow/json-schema": "^5.2.11",
+                "php": "^5.3.2 || ^7.0 || ^8.0",
                 "psr/log": "^1.0",
+                "react/promise": "^1.2 || ^2.7",
                 "seld/jsonlint": "^1.4",
                 "seld/phar-utils": "^1.0",
-                "symfony/console": "^2.7 || ^3.0 || ^4.0 || ^5.0",
-                "symfony/filesystem": "^2.7 || ^3.0 || ^4.0 || ^5.0",
-                "symfony/finder": "^2.7 || ^3.0 || ^4.0 || ^5.0",
-                "symfony/process": "^2.7 || ^3.0 || ^4.0 || ^5.0"
-            },
-            "conflict": {
-                "symfony/console": "2.8.38",
-                "symfony/phpunit-bridge": "3.4.40"
+                "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.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": "^3.4"
+                "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": "1.10-dev"
+                    "dev-master": "2.1-dev"
                 }
             },
             "autoload": {
                 {
                     "name": "Nils Adermann",
                     "email": "[email protected]",
-                    "homepage": "http://www.naderman.de"
+                    "homepage": "https://www.naderman.de"
                 },
                 {
                     "name": "Jordi Boggiano",
                     "email": "[email protected]",
-                    "homepage": "http://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.",
                 "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.1.8"
+            },
             "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": "2020-05-06T08:28:10+00:00"
+            "time": "2021-09-15T11:55:15+00:00"
         },
         {
-            "name": "composer/semver",
-            "version": "1.5.1",
+            "name": "composer/metadata-minifier",
+            "version": "1.0.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/composer/semver.git",
-                "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de"
+                "url": "https://github.com/composer/metadata-minifier.git",
+                "reference": "c549d23829536f0d0e984aaabbf02af91f443207"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/semver/zipball/c6bea70230ef4dd483e6bbcab6005f682ed3a8de",
-                "reference": "c6bea70230ef4dd483e6bbcab6005f682ed3a8de",
+                "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207",
+                "reference": "c549d23829536f0d0e984aaabbf02af91f443207",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.3.2 || ^7.0"
+                "php": "^5.3.2 || ^7.0 || ^8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.5 || ^5.0.5"
+                "composer/composer": "^2",
+                "phpstan/phpstan": "^0.12.55",
+                "symfony/phpunit-bridge": "^4.2 || ^5"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.x-dev"
+                    "dev-main": "1.x-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Composer\\Semver\\": "src"
+                    "Composer\\MetadataMinifier\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                 "MIT"
             ],
             "authors": [
-                {
-                    "name": "Nils Adermann",
-                    "email": "[email protected]",
-                    "homepage": "http://www.naderman.de"
-                },
                 {
                     "name": "Jordi Boggiano",
                     "email": "[email protected]",
                     "homepage": "http://seld.be"
-                },
-                {
-                    "name": "Rob Bast",
-                    "email": "[email protected]",
-                    "homepage": "http://robbast.nl"
                 }
             ],
-            "description": "Semver library that offers utilities, version constraint parsing and validation.",
+            "description": "Small utility library that handles metadata minification and expansion.",
             "keywords": [
-                "semantic",
-                "semver",
-                "validation",
-                "versioning"
+                "composer",
+                "compression"
+            ],
+            "support": {
+                "issues": "https://github.com/composer/metadata-minifier/issues",
+                "source": "https://github.com/composer/metadata-minifier/tree/1.0.0"
+            },
+            "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": "2020-01-13T12:06:48+00:00"
+            "time": "2021-04-07T13:37:33+00:00"
         },
         {
-            "name": "composer/spdx-licenses",
-            "version": "1.5.3",
+            "name": "composer/semver",
+            "version": "3.2.5",
             "source": {
                 "type": "git",
-                "url": "https://github.com/composer/spdx-licenses.git",
-                "reference": "0c3e51e1880ca149682332770e25977c70cf9dae"
+                "url": "https://github.com/composer/semver.git",
+                "reference": "31f3ea725711245195f62e54ffa402d8ef2fdba9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/0c3e51e1880ca149682332770e25977c70cf9dae",
-                "reference": "0c3e51e1880ca149682332770e25977c70cf9dae",
+                "url": "https://api.github.com/repos/composer/semver/zipball/31f3ea725711245195f62e54ffa402d8ef2fdba9",
+                "reference": "31f3ea725711245195f62e54ffa402d8ef2fdba9",
                 "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.54",
+                "symfony/phpunit-bridge": "^4.2 || ^5"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.x-dev"
+                    "dev-main": "3.x-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "Composer\\Spdx\\": "src"
+                    "Composer\\Semver\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
                     "homepage": "http://robbast.nl"
                 }
             ],
-            "description": "SPDX licenses list and validation library.",
+            "description": "Semver library that offers utilities, version constraint parsing and validation.",
+            "keywords": [
+                "semantic",
+                "semver",
+                "validation",
+                "versioning"
+            ],
+            "support": {
+                "irc": "irc://irc.freenode.org/composer",
+                "issues": "https://github.com/composer/semver/issues",
+                "source": "https://github.com/composer/semver/tree/3.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": "2021-05-24T12:41:47+00:00"
+        },
+        {
+            "name": "composer/spdx-licenses",
+            "version": "1.5.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/spdx-licenses.git",
+                "reference": "de30328a7af8680efdc03e396aad24befd513200"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/de30328a7af8680efdc03e396aad24befd513200",
+                "reference": "de30328a7af8680efdc03e396aad24befd513200",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3.2 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 7"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Spdx\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nils Adermann",
+                    "email": "[email protected]",
+                    "homepage": "http://www.naderman.de"
+                },
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "[email protected]",
+                    "homepage": "http://seld.be"
+                },
+                {
+                    "name": "Rob Bast",
+                    "email": "[email protected]",
+                    "homepage": "http://robbast.nl"
+                }
+            ],
+            "description": "SPDX licenses list and validation library.",
             "keywords": [
                 "license",
                 "spdx",
                 "validator"
             ],
-            "time": "2020-02-14T07:44:31+00:00"
+            "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"
+            },
+            "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": "2020-12-03T16:04:16+00:00"
         },
         {
             "name": "composer/xdebug-handler",
-            "version": "1.4.1",
+            "version": "2.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/xdebug-handler.git",
-                "reference": "1ab9842d69e64fb3a01be6b656501032d1b78cb7"
+                "reference": "84674dd3a7575ba617f5a76d7e9e29a7d3891339"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/1ab9842d69e64fb3a01be6b656501032d1b78cb7",
-                "reference": "1ab9842d69e64fb3a01be6b656501032d1b78cb7",
+                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/84674dd3a7575ba617f5a76d7e9e29a7d3891339",
+                "reference": "84674dd3a7575ba617f5a76d7e9e29a7d3891339",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.3.2 || ^7.0 || ^8.0",
-                "psr/log": "^1.0"
+                "psr/log": "^1 || ^2 || ^3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8"
+                "phpstan/phpstan": "^0.12.55",
+                "symfony/phpunit-bridge": "^4.2 || ^5"
             },
             "type": "library",
             "autoload": {
                 "Xdebug",
                 "performance"
             ],
-            "time": "2020-03-01T12:26:26+00:00"
+            "support": {
+                "irc": "irc://irc.freenode.org/composer",
+                "issues": "https://github.com/composer/xdebug-handler/issues",
+                "source": "https://github.com/composer/xdebug-handler/tree/2.0.2"
+            },
+            "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": "2021-07-31T17:03:58+00:00"
         },
         {
             "name": "doctrine/instantiator",
-            "version": "1.3.0",
+            "version": "1.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/instantiator.git",
-                "reference": "ae466f726242e637cebdd526a7d991b9433bacf1"
+                "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/ae466f726242e637cebdd526a7d991b9433bacf1",
-                "reference": "ae466f726242e637cebdd526a7d991b9433bacf1",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b",
+                "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "require-dev": {
-                "doctrine/coding-standard": "^6.0",
+                "doctrine/coding-standard": "^8.0",
                 "ext-pdo": "*",
                 "ext-phar": "*",
-                "phpbench/phpbench": "^0.13",
-                "phpstan/phpstan-phpunit": "^0.11",
-                "phpstan/phpstan-shim": "^0.11",
-                "phpunit/phpunit": "^7.0"
+                "phpbench/phpbench": "^0.13 || 1.0.0-alpha2",
+                "phpstan/phpstan": "^0.12",
+                "phpstan/phpstan-phpunit": "^0.12",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.2.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
                 {
                     "name": "Marco Pivetta",
                     "email": "[email protected]",
-                    "homepage": "http://ocramius.github.com/"
+                    "homepage": "https://ocramius.github.io/"
                 }
             ],
             "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
                 "constructor",
                 "instantiate"
             ],
-            "time": "2019-10-21T16:45:58+00:00"
+            "support": {
+                "issues": "https://github.com/doctrine/instantiator/issues",
+                "source": "https://github.com/doctrine/instantiator/tree/1.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-11-10T18:47:58+00:00"
         },
         {
-            "name": "fzaninotto/faker",
-            "version": "v1.9.1",
+            "name": "fakerphp/faker",
+            "version": "v1.16.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/fzaninotto/Faker.git",
-                "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f"
+                "url": "https://github.com/FakerPHP/Faker.git",
+                "reference": "271d384d216e5e5c468a6b28feedf95d49f83b35"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/fc10d778e4b84d5bd315dad194661e091d307c6f",
-                "reference": "fc10d778e4b84d5bd315dad194661e091d307c6f",
+                "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/271d384d216e5e5c468a6b28feedf95d49f83b35",
+                "reference": "271d384d216e5e5c468a6b28feedf95d49f83b35",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.3.3 || ^7.0"
+                "php": "^7.1 || ^8.0",
+                "psr/container": "^1.0 || ^2.0",
+                "symfony/deprecation-contracts": "^2.2"
+            },
+            "conflict": {
+                "fzaninotto/faker": "*"
             },
             "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.4.1",
                 "ext-intl": "*",
-                "phpunit/phpunit": "^4.8.35 || ^5.7",
-                "squizlabs/php_codesniffer": "^2.9.2"
+                "symfony/phpunit-bridge": "^4.4 || ^5.2"
+            },
+            "suggest": {
+                "ext-curl": "Required by Faker\\Provider\\Image to download images.",
+                "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
+                "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
+                "ext-mbstring": "Required for multibyte Unicode string functionality."
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.9-dev"
+                    "dev-main": "v1.16-dev"
                 }
             },
             "autoload": {
                 "faker",
                 "fixtures"
             ],
-            "time": "2019-12-12T13:22:17+00:00"
+            "support": {
+                "issues": "https://github.com/FakerPHP/Faker/issues",
+                "source": "https://github.com/FakerPHP/Faker/tree/v1.16.0"
+            },
+            "time": "2021-09-06T14:53:37+00:00"
         },
         {
             "name": "hamcrest/hamcrest-php",
-            "version": "v2.0.0",
+            "version": "v2.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/hamcrest/hamcrest-php.git",
-                "reference": "776503d3a8e85d4f9a1148614f95b7a608b046ad"
+                "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/776503d3a8e85d4f9a1148614f95b7a608b046ad",
-                "reference": "776503d3a8e85d4f9a1148614f95b7a608b046ad",
+                "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
+                "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.3|^7.0"
+                "php": "^5.3|^7.0|^8.0"
             },
             "replace": {
                 "cordoval/hamcrest-php": "*",
                 "kodova/hamcrest-php": "*"
             },
             "require-dev": {
-                "phpunit/php-file-iterator": "1.3.3",
-                "phpunit/phpunit": "~4.0",
-                "satooshi/php-coveralls": "^1.0"
+                "phpunit/php-file-iterator": "^1.4 || ^2.0",
+                "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0-dev"
+                    "dev-master": "2.1-dev"
                 }
             },
             "autoload": {
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
-                "BSD"
+                "BSD-3-Clause"
             ],
             "description": "This is the PHP port of Hamcrest Matchers",
             "keywords": [
                 "test"
             ],
-            "time": "2016-01-20T08:20:44+00:00"
+            "support": {
+                "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+                "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1"
+            },
+            "time": "2020-07-09T08:09:16+00:00"
         },
         {
             "name": "justinrainbow/json-schema",
-            "version": "5.2.9",
+            "version": "5.2.11",
             "source": {
                 "type": "git",
                 "url": "https://github.com/justinrainbow/json-schema.git",
-                "reference": "44c6787311242a979fa15c704327c20e7221a0e4"
+                "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/44c6787311242a979fa15c704327c20e7221a0e4",
-                "reference": "44c6787311242a979fa15c704327c20e7221a0e4",
+                "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ab6744b7296ded80f8cc4f9509abbff393399aa",
+                "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa",
                 "shasum": ""
             },
             "require": {
                 "json",
                 "schema"
             ],
-            "time": "2019-09-25T14:49:45+00:00"
-        },
-        {
-            "name": "laravel/browser-kit-testing",
-            "version": "v5.1.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/laravel/browser-kit-testing.git",
-                "reference": "cb0cf22cf38fe8796842adc8b9ad550ded2a1377"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/laravel/browser-kit-testing/zipball/cb0cf22cf38fe8796842adc8b9ad550ded2a1377",
-                "reference": "cb0cf22cf38fe8796842adc8b9ad550ded2a1377",
-                "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",
-                "phpunit/phpunit": "^7.0|^8.0",
-                "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.0-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Laravel\\BrowserKitTesting\\": "src/"
-                }
+            "support": {
+                "issues": "https://github.com/justinrainbow/json-schema/issues",
+                "source": "https://github.com/justinrainbow/json-schema/tree/5.2.11"
             },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Taylor Otwell",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "Provides backwards compatibility for BrowserKit testing in the latest Laravel release.",
-            "keywords": [
-                "laravel",
-                "testing"
-            ],
-            "time": "2019-07-30T14:57:44+00:00"
+            "time": "2021-07-22T09:24:00+00:00"
         },
         {
             "name": "maximebf/debugbar",
-            "version": "v1.16.3",
+            "version": "v1.17.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/maximebf/php-debugbar.git",
-                "reference": "1a1605b8e9bacb34cc0c6278206d699772e1d372"
+                "reference": "0a3532556be0145603f8a9de23e76dc28eed7054"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/1a1605b8e9bacb34cc0c6278206d699772e1d372",
-                "reference": "1a1605b8e9bacb34cc0c6278206d699772e1d372",
+                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/0a3532556be0145603f8a9de23e76dc28eed7054",
+                "reference": "0a3532556be0145603f8a9de23e76dc28eed7054",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1",
+                "php": "^7.1|^8",
                 "psr/log": "^1.0",
                 "symfony/var-dumper": "^2.6|^3|^4|^5"
             },
             "require-dev": {
-                "phpunit/phpunit": "^5"
+                "phpunit/phpunit": "^7.5.20 || ^9.4.2"
             },
             "suggest": {
                 "kriswallsmith/assetic": "The best way to manage assets",
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.16-dev"
+                    "dev-master": "1.17-dev"
                 }
             },
             "autoload": {
                 "debug",
                 "debugbar"
             ],
-            "time": "2020-05-06T07:06:27+00:00"
+            "support": {
+                "issues": "https://github.com/maximebf/php-debugbar/issues",
+                "source": "https://github.com/maximebf/php-debugbar/tree/v1.17.1"
+            },
+            "time": "2021-08-01T09:19:02+00:00"
         },
         {
             "name": "mockery/mockery",
-            "version": "1.3.1",
+            "version": "1.4.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mockery/mockery.git",
-                "reference": "f69bbde7d7a75d6b2862d9ca8fab1cd28014b4be"
+                "reference": "e01123a0e847d52d186c5eb4b9bf58b0c6d00346"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mockery/mockery/zipball/f69bbde7d7a75d6b2862d9ca8fab1cd28014b4be",
-                "reference": "f69bbde7d7a75d6b2862d9ca8fab1cd28014b4be",
+                "url": "https://api.github.com/repos/mockery/mockery/zipball/e01123a0e847d52d186c5eb4b9bf58b0c6d00346",
+                "reference": "e01123a0e847d52d186c5eb4b9bf58b0c6d00346",
                 "shasum": ""
             },
             "require": {
-                "hamcrest/hamcrest-php": "~2.0",
+                "hamcrest/hamcrest-php": "^2.0.1",
                 "lib-pcre": ">=7.0",
-                "php": ">=5.6.0"
+                "php": "^7.3 || ^8.0"
+            },
+            "conflict": {
+                "phpunit/phpunit": "<8.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "~5.7.10|~6.5|~7.0|~8.0"
+                "phpunit/phpunit": "^8.5 || ^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.3.x-dev"
+                    "dev-master": "1.4.x-dev"
                 }
             },
             "autoload": {
                 "test double",
                 "testing"
             ],
-            "time": "2019-12-26T09:49:15+00:00"
+            "support": {
+                "issues": "https://github.com/mockery/mockery/issues",
+                "source": "https://github.com/mockery/mockery/tree/1.4.4"
+            },
+            "time": "2021-09-13T15:28:59+00:00"
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.9.5",
+            "version": "1.10.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef"
+                "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/b2c28789e80a97badd14145fda39b545d83ca3ef",
-                "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220",
+                "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "replace": {
                 "myclabs/deep-copy": "self.version"
                 "object",
                 "object graph"
             ],
-            "time": "2020-01-17T21:11:47+00:00"
+            "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": "nikic/php-parser",
+            "version": "v4.12.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "6608f01670c3cc5079e18c1dab1104e002579143"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6608f01670c3cc5079e18c1dab1104e002579143",
+                "reference": "6608f01670c3cc5079e18c1dab1104e002579143",
+                "shasum": ""
+            },
+            "require": {
+                "ext-tokenizer": "*",
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.12.0"
+            },
+            "time": "2021-07-21T10:44:31+00:00"
         },
         {
             "name": "phar-io/manifest",
-            "version": "1.0.3",
+            "version": "2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phar-io/manifest.git",
-                "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4"
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
-                "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-phar": "*",
-                "phar-io/version": "^2.0",
-                "php": "^5.6 || ^7.0"
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "2.0.x-dev"
                 }
             },
             "autoload": {
             "authors": [
                 {
                     "name": "Arne Blankerts",
-                    "role": "Developer",
-                    "email": "[email protected]"
+                    "email": "[email protected]",
+                    "role": "Developer"
                 },
                 {
                     "name": "Sebastian Heuer",
-                    "role": "Developer",
-                    "email": "[email protected]"
+                    "email": "[email protected]",
+                    "role": "Developer"
                 },
                 {
                     "name": "Sebastian Bergmann",
-                    "role": "Developer",
-                    "email": "[email protected]"
+                    "email": "[email protected]",
+                    "role": "Developer"
                 }
             ],
             "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
-            "time": "2018-07-08T19:23:20+00:00"
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.3"
+            },
+            "time": "2021-07-20T11:28:43+00:00"
         },
         {
             "name": "phar-io/version",
-            "version": "2.0.1",
+            "version": "3.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phar-io/version.git",
-                "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6"
+                "reference": "bae7c545bef187884426f042434e561ab1ddb182"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6",
-                "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/bae7c545bef187884426f042434e561ab1ddb182",
+                "reference": "bae7c545bef187884426f042434e561ab1ddb182",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.6 || ^7.0"
+                "php": "^7.2 || ^8.0"
             },
             "type": "library",
             "autoload": {
                 }
             ],
             "description": "Library for handling version information and constraints",
-            "time": "2018-07-08T19:19:57+00:00"
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.1.0"
+            },
+            "time": "2021-02-23T14:00:09+00:00"
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "2.1.0",
+            "version": "2.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b"
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
-                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
                 "shasum": ""
             },
             "require": {
-                "php": ">=7.1"
+                "php": "^7.2 || ^8.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.x-dev"
+                    "dev-2.x": "2.x-dev"
                 }
             },
             "autoload": {
                 "reflection",
                 "static analysis"
             ],
-            "time": "2020-04-27T09:25:28+00:00"
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+            },
+            "time": "2020-06-27T09:03:43+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
-            "version": "5.1.0",
+            "version": "5.2.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "cd72d394ca794d3466a3b2fc09d5a6c1dc86b47e"
+                "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/cd72d394ca794d3466a3b2fc09d5a6c1dc86b47e",
-                "reference": "cd72d394ca794d3466a3b2fc09d5a6c1dc86b47e",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/069a785b2141f5bcf49f3e353548dc1cce6df556",
+                "reference": "069a785b2141f5bcf49f3e353548dc1cce6df556",
                 "shasum": ""
             },
             "require": {
-                "ext-filter": "^7.1",
-                "php": "^7.2",
-                "phpdocumentor/reflection-common": "^2.0",
-                "phpdocumentor/type-resolver": "^1.0",
-                "webmozart/assert": "^1"
+                "ext-filter": "*",
+                "php": "^7.2 || ^8.0",
+                "phpdocumentor/reflection-common": "^2.2",
+                "phpdocumentor/type-resolver": "^1.3",
+                "webmozart/assert": "^1.9.1"
             },
             "require-dev": {
-                "doctrine/instantiator": "^1",
-                "mockery/mockery": "^1"
+                "mockery/mockery": "~1.3.2"
             },
             "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.",
-            "time": "2020-02-22T12:28:44+00:00"
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master"
+            },
+            "time": "2020-09-03T19:13:55+00:00"
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "1.1.0",
+            "version": "1.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "7462d5f123dfc080dfdf26897032a6513644fc95"
+                "reference": "30f38bffc6f24293dadd1823936372dfa9e86e2f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/7462d5f123dfc080dfdf26897032a6513644fc95",
-                "reference": "7462d5f123dfc080dfdf26897032a6513644fc95",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/30f38bffc6f24293dadd1823936372dfa9e86e2f",
+                "reference": "30f38bffc6f24293dadd1823936372dfa9e86e2f",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2",
+                "php": "^7.2 || ^8.0",
                 "phpdocumentor/reflection-common": "^2.0"
             },
             "require-dev": {
-                "ext-tokenizer": "^7.2",
-                "mockery/mockery": "~1"
+                "ext-tokenizer": "*",
+                "psalm/phar": "^4.8"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.x-dev"
+                    "dev-1.x": "1.x-dev"
                 }
             },
             "autoload": {
                 }
             ],
             "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
-            "time": "2020-02-18T18:59:58+00:00"
+            "support": {
+                "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.0"
+            },
+            "time": "2021-09-17T15:28:14+00:00"
         },
         {
-            "name": "phploc/phploc",
-            "version": "5.0.0",
+            "name": "phpspec/prophecy",
+            "version": "1.14.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/sebastianbergmann/phploc.git",
-                "reference": "5b714ccb7cb8ca29ccf9caf6eb1aed0131d3a884"
+                "url": "https://github.com/phpspec/prophecy.git",
+                "reference": "d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phploc/zipball/5b714ccb7cb8ca29ccf9caf6eb1aed0131d3a884",
-                "reference": "5b714ccb7cb8ca29ccf9caf6eb1aed0131d3a884",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e",
+                "reference": "d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2",
-                "sebastian/finder-facade": "^1.1",
-                "sebastian/version": "^2.0",
-                "symfony/console": "^4.0"
+                "doctrine/instantiator": "^1.2",
+                "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 || ^7.0",
+                "phpunit/phpunit": "^8.0 || ^9.0"
             },
-            "bin": [
-                "phploc"
-            ],
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "5.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "[email protected]",
-                    "role": "lead"
-                }
-            ],
-            "description": "A tool for quickly measuring the size of a PHP project.",
-            "homepage": "https://github.com/sebastianbergmann/phploc",
-            "time": "2019-03-16T10:41:19+00:00"
-        },
-        {
-            "name": "phpspec/prophecy",
-            "version": "v1.10.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "451c3cd1418cf640de218914901e51b064abb093"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093",
-                "reference": "451c3cd1418cf640de218914901e51b064abb093",
-                "shasum": ""
-            },
-            "require": {
-                "doctrine/instantiator": "^1.0.2",
-                "php": "^5.3|^7.0",
-                "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
-                "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0",
-                "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0"
-            },
-            "require-dev": {
-                "phpspec/phpspec": "^2.5 || ^3.2",
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.10.x-dev"
+                    "dev-master": "1.x-dev"
                 }
             },
             "autoload": {
                 "spy",
                 "stub"
             ],
-            "time": "2020-03-05T15:02:03+00:00"
+            "support": {
+                "issues": "https://github.com/phpspec/prophecy/issues",
+                "source": "https://github.com/phpspec/prophecy/tree/1.14.0"
+            },
+            "time": "2021-09-10T09:02:12+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "7.0.10",
+            "version": "9.2.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf"
+                "reference": "d4c798ed8d51506800b441f7a13ecb0f76f12218"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f1884187926fbb755a9aaf0b3836ad3165b478bf",
-                "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d4c798ed8d51506800b441f7a13ecb0f76f12218",
+                "reference": "d4c798ed8d51506800b441f7a13ecb0f76f12218",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
+                "ext-libxml": "*",
                 "ext-xmlwriter": "*",
-                "php": "^7.2",
-                "phpunit/php-file-iterator": "^2.0.2",
-                "phpunit/php-text-template": "^1.2.1",
-                "phpunit/php-token-stream": "^3.1.1",
-                "sebastian/code-unit-reverse-lookup": "^1.0.1",
-                "sebastian/environment": "^4.2.2",
-                "sebastian/version": "^2.0.1",
-                "theseer/tokenizer": "^1.1.3"
+                "nikic/php-parser": "^4.12.0",
+                "php": ">=7.3",
+                "phpunit/php-file-iterator": "^3.0.3",
+                "phpunit/php-text-template": "^2.0.2",
+                "sebastian/code-unit-reverse-lookup": "^2.0.2",
+                "sebastian/complexity": "^2.0",
+                "sebastian/environment": "^5.1.2",
+                "sebastian/lines-of-code": "^1.0.3",
+                "sebastian/version": "^3.0.1",
+                "theseer/tokenizer": "^1.2.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^8.2.2"
+                "phpunit/phpunit": "^9.3"
             },
             "suggest": {
-                "ext-xdebug": "^2.7.2"
+                "ext-pcov": "*",
+                "ext-xdebug": "*"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "7.0-dev"
+                    "dev-master": "9.2-dev"
                 }
             },
             "autoload": {
                 "testing",
                 "xunit"
             ],
-            "time": "2019-11-20T13:55:58+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.7"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-09-17T05:39:03+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
-            "version": "2.0.2",
+            "version": "3.0.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
-                "reference": "050bedf145a257b1ff02746c31894800e5122946"
+                "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946",
-                "reference": "050bedf145a257b1ff02746c31894800e5122946",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8",
+                "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.1"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0.x-dev"
+                    "dev-master": "3.0-dev"
                 }
             },
             "autoload": {
                 "filesystem",
                 "iterator"
             ],
-            "time": "2018-09-13T20:33:42+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:57:25+00:00"
         },
         {
-            "name": "phpunit/php-text-template",
-            "version": "1.2.1",
+            "name": "phpunit/php-invoker",
+            "version": "3.1.1",
             "source": {
                 "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-text-template.git",
-                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
-                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.1-dev"
+                }
+            },
             "autoload": {
                 "classmap": [
                     "src/"
                     "role": "lead"
                 }
             ],
-            "description": "Simple template engine.",
-            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
             "keywords": [
-                "template"
+                "process"
             ],
-            "time": "2015-06-21T13:50:34+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:58:55+00:00"
         },
         {
-            "name": "phpunit/php-timer",
-            "version": "2.1.2",
+            "name": "phpunit/php-text-template",
+            "version": "2.0.4",
             "source": {
                 "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-timer.git",
-                "reference": "1038454804406b0b5f5f520358e78c1c2f71501e"
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e",
-                "reference": "1038454804406b0b5f5f520358e78c1c2f71501e",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.1-dev"
+                    "dev-master": "2.0-dev"
                 }
             },
             "autoload": {
                     "role": "lead"
                 }
             ],
-            "description": "Utility class for timing",
-            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
             "keywords": [
-                "timer"
+                "template"
             ],
-            "time": "2019-06-07T04:22:29+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T05:33:50+00:00"
         },
         {
-            "name": "phpunit/php-token-stream",
-            "version": "3.1.1",
+            "name": "phpunit/php-timer",
+            "version": "5.0.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-token-stream.git",
-                "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff"
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff",
-                "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
                 "shasum": ""
             },
             "require": {
-                "ext-tokenizer": "*",
-                "php": "^7.1"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.1-dev"
+                    "dev-master": "5.0-dev"
                 }
             },
             "autoload": {
             "authors": [
                 {
                     "name": "Sebastian Bergmann",
-                    "email": "[email protected]"
+                    "email": "[email protected]",
+                    "role": "lead"
                 }
             ],
-            "description": "Wrapper around PHP's tokenizer extension.",
-            "homepage": "https://github.com/sebastianbergmann/php-token-stream/",
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
             "keywords": [
-                "tokenizer"
+                "timer"
             ],
-            "time": "2019-09-17T06:23:10+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:16:10+00:00"
         },
         {
             "name": "phpunit/phpunit",
-            "version": "8.5.5",
+            "version": "9.5.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "63dda3b212a0025d380a745f91bdb4d8c985adb7"
+                "reference": "ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/63dda3b212a0025d380a745f91bdb4d8c985adb7",
-                "reference": "63dda3b212a0025d380a745f91bdb4d8c985adb7",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b",
+                "reference": "ea8c2dfb1065eb35a79b3681eee6e6fb0a6f273b",
                 "shasum": ""
             },
             "require": {
-                "doctrine/instantiator": "^1.2.0",
+                "doctrine/instantiator": "^1.3.1",
                 "ext-dom": "*",
                 "ext-json": "*",
                 "ext-libxml": "*",
                 "ext-mbstring": "*",
                 "ext-xml": "*",
                 "ext-xmlwriter": "*",
-                "myclabs/deep-copy": "^1.9.1",
-                "phar-io/manifest": "^1.0.3",
-                "phar-io/version": "^2.0.1",
-                "php": "^7.2",
-                "phpspec/prophecy": "^1.8.1",
-                "phpunit/php-code-coverage": "^7.0.7",
-                "phpunit/php-file-iterator": "^2.0.2",
-                "phpunit/php-text-template": "^1.2.1",
-                "phpunit/php-timer": "^2.1.2",
-                "sebastian/comparator": "^3.0.2",
-                "sebastian/diff": "^3.0.2",
-                "sebastian/environment": "^4.2.2",
-                "sebastian/exporter": "^3.1.1",
-                "sebastian/global-state": "^3.0.0",
-                "sebastian/object-enumerator": "^3.0.3",
-                "sebastian/resource-operations": "^2.0.1",
-                "sebastian/type": "^1.1.3",
-                "sebastian/version": "^2.0.1"
+                "myclabs/deep-copy": "^1.10.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-file-iterator": "^3.0.5",
+                "phpunit/php-invoker": "^3.1.1",
+                "phpunit/php-text-template": "^2.0.3",
+                "phpunit/php-timer": "^5.0.2",
+                "sebastian/cli-parser": "^1.0.1",
+                "sebastian/code-unit": "^1.0.6",
+                "sebastian/comparator": "^4.0.5",
+                "sebastian/diff": "^4.0.3",
+                "sebastian/environment": "^5.1.3",
+                "sebastian/exporter": "^4.0.3",
+                "sebastian/global-state": "^5.0.1",
+                "sebastian/object-enumerator": "^4.0.3",
+                "sebastian/resource-operations": "^3.0.3",
+                "sebastian/type": "^2.3.4",
+                "sebastian/version": "^3.0.2"
             },
             "require-dev": {
-                "ext-pdo": "*"
+                "ext-pdo": "*",
+                "phpspec/prophecy-phpunit": "^2.0.1"
             },
             "suggest": {
                 "ext-soap": "*",
-                "ext-xdebug": "*",
-                "phpunit/php-invoker": "^2.0.0"
+                "ext-xdebug": "*"
             },
             "bin": [
                 "phpunit"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "8.5-dev"
+                    "dev-master": "9.5-dev"
                 }
             },
             "autoload": {
                 "classmap": [
                     "src/"
+                ],
+                "files": [
+                    "src/Framework/Assert/Functions.php"
                 ]
             },
             "notification-url": "https://packagist.org/downloads/",
                 "testing",
                 "xunit"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.9"
+            },
             "funding": [
                 {
                     "url": "https://phpunit.de/donate.html",
                     "type": "github"
                 }
             ],
-            "time": "2020-05-22T13:51:52+00:00"
+            "time": "2021-08-31T06:47:40+00:00"
         },
         {
-            "name": "sebastian/code-unit-reverse-lookup",
+            "name": "react/promise",
+            "version": "v2.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/promise.git",
+                "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/promise/zipball/f3cff96a19736714524ca0dd1d4130de73dbbbc4",
+                "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^7.0 || ^6.5 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Promise\\": "src/"
+                },
+                "files": [
+                    "src/functions_include.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
+            "keywords": [
+                "promise",
+                "promises"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/promise/issues",
+                "source": "https://github.com/reactphp/promise/tree/v2.8.0"
+            },
+            "time": "2020-05-12T15:16:56+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
             "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+                "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "[email protected]",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:08:49+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "1.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "[email protected]",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:08:54+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
-                "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18"
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
-                "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.6 || ^7.0"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^5.7 || ^6.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "2.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Looks up which function or method a line of code belongs to",
             "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
-            "time": "2017-03-04T06:30:41+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:30:19+00:00"
         },
         {
             "name": "sebastian/comparator",
-            "version": "3.0.2",
+            "version": "4.0.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da"
+                "reference": "55f4261989e546dc112258c7a75935a81a7ce382"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
-                "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382",
+                "reference": "55f4261989e546dc112258c7a75935a81a7ce382",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1",
-                "sebastian/diff": "^3.0",
-                "sebastian/exporter": "^3.1"
+                "php": ">=7.3",
+                "sebastian/diff": "^4.0",
+                "sebastian/exporter": "^4.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.1"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0-dev"
+                    "dev-master": "4.0-dev"
                 }
             },
             "autoload": {
                 "BSD-3-Clause"
             ],
             "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "[email protected]"
+                },
                 {
                     "name": "Jeff Welch",
                     "email": "[email protected]"
                 {
                     "name": "Bernhard Schussek",
                     "email": "[email protected]"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "[email protected]"
                 }
             ],
             "description": "Provides the functionality to compare PHP values for equality",
                 "compare",
                 "equality"
             ],
-            "time": "2018-07-12T15:12:46+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T15:49:45+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
+                "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.7",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "[email protected]",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T15:52:27+00:00"
         },
         {
             "name": "sebastian/diff",
-            "version": "3.0.2",
+            "version": "4.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/diff.git",
-                "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29"
+                "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
-                "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+                "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.5 || ^8.0",
-                "symfony/process": "^2 || ^3.3 || ^4"
+                "phpunit/phpunit": "^9.3",
+                "symfony/process": "^4.2 || ^5"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0-dev"
+                    "dev-master": "4.0-dev"
                 }
             },
             "autoload": {
                 "BSD-3-Clause"
             ],
             "authors": [
-                {
-                    "name": "Kore Nordmann",
-                    "email": "[email protected]"
-                },
                 {
                     "name": "Sebastian Bergmann",
                     "email": "[email protected]"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "[email protected]"
                 }
             ],
             "description": "Diff implementation",
                 "unidiff",
                 "unified diff"
             ],
-            "time": "2019-02-04T06:01:07+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:10:38+00:00"
         },
         {
             "name": "sebastian/environment",
-            "version": "4.2.3",
+            "version": "5.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/environment.git",
-                "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368"
+                "reference": "388b6ced16caa751030f6a69e588299fa09200ac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
-                "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac",
+                "reference": "388b6ced16caa751030f6a69e588299fa09200ac",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^7.5"
+                "phpunit/phpunit": "^9.3"
             },
             "suggest": {
                 "ext-posix": "*"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "4.2-dev"
+                    "dev-master": "5.1-dev"
                 }
             },
             "autoload": {
                 "environment",
                 "hhvm"
             ],
-            "time": "2019-11-20T08:46:58+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:52:38+00:00"
         },
         {
             "name": "sebastian/exporter",
-            "version": "3.1.2",
+            "version": "4.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e"
+                "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e",
-                "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65",
+                "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.0",
-                "sebastian/recursion-context": "^3.0"
+                "php": ">=7.3",
+                "sebastian/recursion-context": "^4.0"
             },
             "require-dev": {
                 "ext-mbstring": "*",
-                "phpunit/phpunit": "^6.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.1.x-dev"
+                    "dev-master": "4.0-dev"
                 }
             },
             "autoload": {
                 "export",
                 "exporter"
             ],
-            "time": "2019-09-14T09:02:43+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:24:23+00:00"
         },
         {
-            "name": "sebastian/finder-facade",
-            "version": "1.2.3",
+            "name": "sebastian/global-state",
+            "version": "5.0.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/sebastianbergmann/finder-facade.git",
-                "reference": "167c45d131f7fc3d159f56f191a0a22228765e16"
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/finder-facade/zipball/167c45d131f7fc3d159f56f191a0a22228765e16",
-                "reference": "167c45d131f7fc3d159f56f191a0a22228765e16",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/23bd5951f7ff26f12d4e3242864df3e08dec4e49",
+                "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1",
-                "symfony/finder": "^2.3|^3.0|^4.0|^5.0",
-                "theseer/fdomdocument": "^1.6"
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-uopz": "*"
             },
             "type": "library",
             "extra": {
-                "branch-alias": []
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
             },
             "autoload": {
                 "classmap": [
             "authors": [
                 {
                     "name": "Sebastian Bergmann",
-                    "email": "[email protected]",
-                    "role": "lead"
+                    "email": "[email protected]"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "http://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
                 }
             ],
-            "description": "FinderFacade is a convenience wrapper for Symfony's Finder component.",
-            "homepage": "https://github.com/sebastianbergmann/finder-facade",
-            "time": "2020-01-16T08:08:45+00:00"
+            "time": "2021-06-11T13:31:12+00:00"
         },
         {
-            "name": "sebastian/global-state",
-            "version": "3.0.0",
+            "name": "sebastian/lines-of-code",
+            "version": "1.0.3",
             "source": {
                 "type": "git",
-                "url": "https://github.com/sebastianbergmann/global-state.git",
-                "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4"
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
-                "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+                "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2",
-                "sebastian/object-reflector": "^1.1.1",
-                "sebastian/recursion-context": "^3.0"
+                "nikic/php-parser": "^4.6",
+                "php": ">=7.3"
             },
             "require-dev": {
-                "ext-dom": "*",
-                "phpunit/phpunit": "^8.0"
-            },
-            "suggest": {
-                "ext-uopz": "*"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0-dev"
+                    "dev-master": "1.0-dev"
                 }
             },
             "autoload": {
             "authors": [
                 {
                     "name": "Sebastian Bergmann",
-                    "email": "[email protected]"
+                    "email": "[email protected]",
+                    "role": "lead"
                 }
             ],
-            "description": "Snapshotting of global state",
-            "homepage": "http://www.github.com/sebastianbergmann/global-state",
-            "keywords": [
-                "global state"
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
             ],
-            "time": "2019-02-01T05:30:01+00:00"
+            "time": "2020-11-28T06:42:11+00:00"
         },
         {
             "name": "sebastian/object-enumerator",
-            "version": "3.0.3",
+            "version": "4.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/object-enumerator.git",
-                "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5"
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5",
-                "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.0",
-                "sebastian/object-reflector": "^1.1.1",
-                "sebastian/recursion-context": "^3.0"
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
             },
             "require-dev": {
-                "phpunit/phpunit": "^6.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0.x-dev"
+                    "dev-master": "4.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Traverses array structures and object graphs to enumerate all referenced objects",
             "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
-            "time": "2017-08-03T12:35:26+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:12:34+00:00"
         },
         {
             "name": "sebastian/object-reflector",
-            "version": "1.1.1",
+            "version": "2.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/object-reflector.git",
-                "reference": "773f97c67f28de00d397be301821b06708fca0be"
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be",
-                "reference": "773f97c67f28de00d397be301821b06708fca0be",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.0"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^6.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.1-dev"
+                    "dev-master": "2.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Allows reflection of object attributes, including inherited and non-public ones",
             "homepage": "https://github.com/sebastianbergmann/object-reflector/",
-            "time": "2017-03-29T09:07:27+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:14:26+00:00"
         },
         {
             "name": "sebastian/recursion-context",
-            "version": "3.0.0",
+            "version": "4.0.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8"
+                "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
-                "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172",
+                "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.0"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^6.0"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0.x-dev"
+                    "dev-master": "4.0-dev"
                 }
             },
             "autoload": {
                 "BSD-3-Clause"
             ],
             "authors": [
-                {
-                    "name": "Jeff Welch",
-                    "email": "[email protected]"
-                },
                 {
                     "name": "Sebastian Bergmann",
                     "email": "[email protected]"
                 },
+                {
+                    "name": "Jeff Welch",
+                    "email": "[email protected]"
+                },
                 {
                     "name": "Adam Harvey",
                     "email": "[email protected]"
             ],
             "description": "Provides functionality to recursively process PHP variables",
             "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
-            "time": "2017-03-03T06:23:57+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:17:30+00:00"
         },
         {
             "name": "sebastian/resource-operations",
-            "version": "2.0.1",
+            "version": "3.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/resource-operations.git",
-                "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9"
+                "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
-                "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
+                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+                "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0-dev"
+                    "dev-master": "3.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Provides a list of PHP built-in functions that operate on resources",
             "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
-            "time": "2018-10-04T04:07:39+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+                "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:45:17+00:00"
         },
         {
             "name": "sebastian/type",
-            "version": "1.1.3",
+            "version": "2.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/type.git",
-                "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3"
+                "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/3aaaa15fa71d27650d62a948be022fe3b48541a3",
-                "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8cd8a1c753c90bc1a0f5372170e3e489136f914",
+                "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.2"
+                "php": ">=7.3"
             },
             "require-dev": {
-                "phpunit/phpunit": "^8.2"
+                "phpunit/phpunit": "^9.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.1-dev"
+                    "dev-master": "2.3-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Collection of value objects that represent the types of the PHP type system",
             "homepage": "https://github.com/sebastianbergmann/type",
-            "time": "2019-07-02T08:10:15+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "source": "https://github.com/sebastianbergmann/type/tree/2.3.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-06-15T12:49:02+00:00"
         },
         {
             "name": "sebastian/version",
-            "version": "2.0.1",
+            "version": "3.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/version.git",
-                "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019"
+                "reference": "c6c1022351a901512170118436c764e473f6de8c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019",
-                "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.6"
+                "php": ">=7.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "2.0.x-dev"
+                    "dev-master": "3.0-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Library that helps with managing the version number of Git-hosted PHP projects",
             "homepage": "https://github.com/sebastianbergmann/version",
-            "time": "2016-10-03T07:35:21+00:00"
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:39:44+00:00"
         },
         {
             "name": "seld/jsonlint",
-            "version": "1.8.0",
+            "version": "1.8.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/jsonlint.git",
-                "reference": "ff2aa5420bfbc296cf6a0bc785fa5b35736de7c1"
+                "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/ff2aa5420bfbc296cf6a0bc785fa5b35736de7c1",
-                "reference": "ff2aa5420bfbc296cf6a0bc785fa5b35736de7c1",
+                "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9ad6ce79c342fbd44df10ea95511a1b24dee5b57",
+                "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57",
                 "shasum": ""
             },
             "require": {
                 "parser",
                 "validator"
             ],
+            "support": {
+                "issues": "https://github.com/Seldaek/jsonlint/issues",
+                "source": "https://github.com/Seldaek/jsonlint/tree/1.8.3"
+            },
             "funding": [
                 {
                     "url": "https://github.com/Seldaek",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-30T19:05:18+00:00"
+            "time": "2020-11-11T09:19:24+00:00"
         },
         {
             "name": "seld/phar-utils",
-            "version": "1.1.0",
+            "version": "1.1.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/phar-utils.git",
-                "reference": "8800503d56b9867d43d9c303b9cbcc26016e82f0"
+                "reference": "749042a2315705d2dfbbc59234dd9ceb22bf3ff0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/8800503d56b9867d43d9c303b9cbcc26016e82f0",
-                "reference": "8800503d56b9867d43d9c303b9cbcc26016e82f0",
+                "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/749042a2315705d2dfbbc59234dd9ceb22bf3ff0",
+                "reference": "749042a2315705d2dfbbc59234dd9ceb22bf3ff0",
                 "shasum": ""
             },
             "require": {
             "keywords": [
                 "phar"
             ],
-            "time": "2020-02-14T15:25:33+00:00"
-        },
-        {
-            "name": "squizlabs/php_codesniffer",
-            "version": "3.5.5",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
-                "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6",
-                "shasum": ""
-            },
-            "require": {
-                "ext-simplexml": "*",
-                "ext-tokenizer": "*",
-                "ext-xmlwriter": "*",
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+            "support": {
+                "issues": "https://github.com/Seldaek/phar-utils/issues",
+                "source": "https://github.com/Seldaek/phar-utils/tree/1.1.2"
             },
-            "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"
-            ],
-            "time": "2020-04-17T01:09:41+00:00"
+            "time": "2021-08-19T21:01:38+00:00"
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v4.4.8",
+            "version": "v5.3.7",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "4d0fb3374324071ecdd94898367a3fa4b5563162"
+                "reference": "c7eef3a60ccfdd8eafe07f81652e769ac9c7146c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4d0fb3374324071ecdd94898367a3fa4b5563162",
-                "reference": "4d0fb3374324071ecdd94898367a3fa4b5563162",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/c7eef3a60ccfdd8eafe07f81652e769ac9c7146c",
+                "reference": "c7eef3a60ccfdd8eafe07f81652e769ac9c7146c",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1",
                 "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"
             },
             "suggest": {
                 "symfony/css-selector": ""
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\DomCrawler\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony DomCrawler Component",
+            "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/dom-crawler/tree/v5.3.7"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-03-29T19:12:22+00:00"
+            "time": "2021-08-29T19:32:13+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v4.4.8",
+            "version": "v5.3.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "a3ebf3bfd8a98a147c010a568add5a8aa4edea0f"
+                "reference": "343f4fe324383ca46792cae728a3b6e2f708fb32"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/a3ebf3bfd8a98a147c010a568add5a8aa4edea0f",
-                "reference": "a3ebf3bfd8a98a147c010a568add5a8aa4edea0f",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/343f4fe324383ca46792cae728a3b6e2f708fb32",
+                "reference": "343f4fe324383ca46792cae728a3b6e2f708fb32",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1.3",
-                "symfony/polyfill-ctype": "~1.8"
+                "php": ">=7.2.5",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-php80": "^1.16"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Symfony\\Component\\Filesystem\\": ""
                     "homepage": "https://symfony.com/contributors"
                 }
             ],
-            "description": "Symfony Filesystem Component",
+            "description": "Provides basic utilities for the filesystem",
             "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/filesystem/tree/v5.3.4"
+            },
             "funding": [
                 {
                     "url": "https://symfony.com/sponsor",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-04-12T14:39:55+00:00"
-        },
-        {
-            "name": "theseer/fdomdocument",
-            "version": "1.6.6",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/theseer/fDOMDocument.git",
-                "reference": "6e8203e40a32a9c770bcb62fe37e68b948da6dca"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/theseer/fDOMDocument/zipball/6e8203e40a32a9c770bcb62fe37e68b948da6dca",
-                "reference": "6e8203e40a32a9c770bcb62fe37e68b948da6dca",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "lib-libxml": "*",
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "role": "lead",
-                    "email": "[email protected]"
-                }
-            ],
-            "description": "The classes contained within this repository extend the standard DOM to use exceptions at all occasions of errors instead of PHP warnings or notices. They also add various custom methods and shortcuts for convenience and to simplify the usage of DOM.",
-            "homepage": "https://github.com/theseer/fDOMDocument",
-            "time": "2017-06-30T11:53:12+00:00"
+            "time": "2021-07-21T12:40:44+00:00"
         },
         {
             "name": "theseer/tokenizer",
-            "version": "1.1.3",
+            "version": "1.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/theseer/tokenizer.git",
-                "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9"
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
-                "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-tokenizer": "*",
                 "ext-xmlwriter": "*",
-                "php": "^7.0"
+                "php": "^7.2 || ^8.0"
             },
             "type": "library",
             "autoload": {
             "authors": [
                 {
                     "name": "Arne Blankerts",
-                    "role": "Developer",
-                    "email": "[email protected]"
+                    "email": "[email protected]",
+                    "role": "Developer"
                 }
             ],
             "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
-            "time": "2019-06-13T22:48:21+00:00"
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-07-28T10:34:58+00:00"
         },
         {
             "name": "webmozart/assert",
-            "version": "1.8.0",
+            "version": "1.10.0",
             "source": {
                 "type": "git",
-                "url": "https://github.com/webmozart/assert.git",
-                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6"
+                "url": "https://github.com/webmozarts/assert.git",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
-                "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6",
+                "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.3.3 || ^7.0",
+                "php": "^7.2 || ^8.0",
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
-                "vimeo/psalm": "<3.9.1"
+                "phpstan/phpstan": "<0.12.20",
+                "vimeo/psalm": "<4.6.1 || 4.6.2"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.8.36 || ^7.5.13"
+                "phpunit/phpunit": "^8.5.13"
             },
             "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.10-dev"
+                }
+            },
             "autoload": {
                 "psr-4": {
                     "Webmozart\\Assert\\": "src/"
                 "check",
                 "validate"
             ],
-            "time": "2020-04-18T12:12:48+00:00"
-        },
-        {
-            "name": "wnx/laravel-stats",
-            "version": "v2.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/stefanzweifel/laravel-stats.git",
-                "reference": "e86ebfdd149383b18a41fe3efa1601d82d447140"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/stefanzweifel/laravel-stats/zipball/e86ebfdd149383b18a41fe3efa1601d82d447140",
-                "reference": "e86ebfdd149383b18a41fe3efa1601d82d447140",
-                "shasum": ""
-            },
-            "require": {
-                "illuminate/console": "~5.8.0|^6.0|^7.0",
-                "illuminate/support": "~5.8.0|^6.0|^7.0",
-                "php": ">=7.2.0",
-                "phploc/phploc": "~5.0|~6.0",
-                "symfony/finder": "~4.0"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.15",
-                "laravel/browser-kit-testing": "~5.0",
-                "laravel/dusk": "~5.0",
-                "mockery/mockery": "^1.1",
-                "orchestra/testbench": "^3.8|^4.0|^5.0",
-                "phpunit/phpunit": "8.*|9.*"
-            },
-            "type": "library",
-            "extra": {
-                "laravel": {
-                    "providers": [
-                        "Wnx\\LaravelStats\\StatsServiceProvider"
-                    ]
-                }
+            "support": {
+                "issues": "https://github.com/webmozarts/assert/issues",
+                "source": "https://github.com/webmozarts/assert/tree/1.10.0"
             },
-            "autoload": {
-                "psr-4": {
-                    "Wnx\\LaravelStats\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Stefan Zweifel",
-                    "email": "[email protected]",
-                    "homepage": "https://stefanzweifel.io",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Get insights about your Laravel Project",
-            "homepage": "https://github.com/stefanzweifel/laravel-stats",
-            "keywords": [
-                "laravel",
-                "statistics",
-                "stats",
-                "wnx"
-            ],
-            "time": "2020-02-22T19:09:14+00:00"
+            "time": "2021-03-09T10:59:23+00:00"
         }
     ],
     "aliases": [],
     "prefer-stable": true,
     "prefer-lowest": false,
     "platform": {
-        "php": "^7.2",
+        "php": "^7.3|^8.0",
         "ext-curl": "*",
         "ext-dom": "*",
+        "ext-fileinfo": "*",
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
-        "ext-tidy": "*",
         "ext-xml": "*"
     },
     "platform-dev": [],
     "platform-overrides": {
-        "php": "7.2.0"
+        "php": "7.3.0"
     },
-    "plugin-api-version": "1.1.0"
+    "plugin-api-version": "2.1.0"
 }
index ddf3c295d22e09023fad7e62eaefc5c198f454a1..dc06455402aa1fcbf88774f6b881848abec5752a 100644 (file)
 */
 
 $factory->define(\BookStack\Auth\User::class, function ($faker) {
+    $name = $faker->name;
+
     return [
-        'name' => $faker->name,
-        'email' => $faker->email,
-        'password' => Str::random(10),
-        'remember_token' => Str::random(10),
-        'email_confirmed' => 1
+        '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\Bookshelf::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Bookshelf::class, function ($faker) {
     return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'description' => $faker->paragraph
+        'name'        => $faker->sentence,
+        'slug'        => Str::random(10),
+        'description' => $faker->paragraph,
     ];
 });
 
-$factory->define(\BookStack\Entities\Book::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Book::class, function ($faker) {
     return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'description' => $faker->paragraph
+        'name'        => $faker->sentence,
+        'slug'        => Str::random(10),
+        'description' => $faker->paragraph,
     ];
 });
 
-$factory->define(\BookStack\Entities\Chapter::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Chapter::class, function ($faker) {
     return [
-        'name' => $faker->sentence,
-        'slug' => Str::random(10),
-        'description' => $faker->paragraph
+        'name'        => $faker->sentence,
+        'slug'        => Str::random(10),
+        'description' => $faker->paragraph,
     ];
 });
 
-$factory->define(\BookStack\Entities\Page::class, function ($faker) {
+$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
+        '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)
+        'description'  => $faker->sentence(10),
     ];
 });
 
 $factory->define(\BookStack\Actions\Tag::class, function ($faker) {
     return [
-        'name' => $faker->city,
-        'value' => $faker->sentence(3)
+        '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
+        'name'        => $faker->slug . '.jpg',
+        'url'         => $faker->url,
+        'path'        => $faker->url,
+        'type'        => 'gallery',
+        'uploaded_to' => 0,
     ];
 });
 
-$factory->define(\BookStack\Actions\Comment::class, function($faker) {
+$factory->define(\BookStack\Actions\Comment::class, function ($faker) {
     $text = $faker->paragraph(1);
-    $html = '<p>' . $text. '</p>';
+    $html = '<p>' . $text . '</p>';
+
     return [
-        'html' => $html,
-        'text' => $text,
-        'parent_id' => null
+        'html'      => $html,
+        'text'      => $text,
+        'parent_id' => null,
     ];
-});
\ No newline at end of file
+});
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 7398ed39885cedde9655f9fb06783016e6c6b75e..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
 {
@@ -15,7 +15,7 @@ class CreateSearchIndexTable extends Migration
     {
         Schema::create('search_terms', function (Blueprint $table) {
             $table->increments('id');
-            $table->string('term', 200);
+            $table->string('term', 180);
             $table->string('entity_type', 100);
             $table->integer('entity_id');
             $table->integer('score');
@@ -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 706a883a387d996055eebc7742ebb3e56f003fe3..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
 {
@@ -14,7 +14,7 @@ class AddRoleExternalAuthId extends Migration
     public function up()
     {
         Schema::table('roles', function (Blueprint $table) {
-            $table->string('external_auth_id', 200)->default('');
+            $table->string('external_auth_id', 180)->default('');
             $table->index('external_auth_id');
         });
     }
index eab3216bbdfa6cc02e2b180ff094683a73f5cfdd..04f46ce509f4122ee2e2fa1ab2cef8d484663713 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 CreateBookshelvesTable extends Migration
 {
@@ -23,7 +23,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.
@@ -37,8 +38,8 @@ class CreateBookshelvesTable extends Migration
 
         Schema::create('bookshelves', function (Blueprint $table) {
             $table->increments('id');
-            $table->string('name', 200);
-            $table->string('slug', 200);
+            $table->string('name', 180);
+            $table->string('slug', 180);
             $table->text('description');
             $table->integer('created_by')->nullable()->default(null);
             $table->integer('updated_by')->nullable()->default(null);
@@ -79,18 +80,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 +108,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();
@@ -119,11 +120,11 @@ class CreateBookshelvesTable extends Migration
         Schema::dropIfExists('bookshelves');
 
         // Drop related polymorphic items
-        DB::table('activities')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('views')->where('viewable_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('tags')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('search_terms')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
-        DB::table('comments')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
+        DB::table('activities')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('views')->where('viewable_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('tags')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('search_terms')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+        DB::table('comments')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
     }
 }
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,
         ]);
     }
 
diff --git a/database/migrations/2020_08_04_111754_drop_joint_permissions_id.php b/database/migrations/2020_08_04_111754_drop_joint_permissions_id.php
new file mode 100644 (file)
index 0000000..bb953a5
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class DropJointPermissionsId extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->dropColumn('id');
+            $table->primary(['role_id', 'entity_type', 'entity_id', 'action'], 'joint_primary');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->dropPrimary(['role_id', 'entity_type', 'entity_id', 'action']);
+        });
+
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->increments('id')->unsigned();
+        });
+    }
+}
diff --git a/database/migrations/2020_08_04_131052_remove_role_name_field.php b/database/migrations/2020_08_04_131052_remove_role_name_field.php
new file mode 100644 (file)
index 0000000..8f99817
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class RemoveRoleNameField extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('roles', function (Blueprint $table) {
+            $table->dropColumn('name');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('roles', function (Blueprint $table) {
+            $table->string('name')->index();
+        });
+
+        DB::table('roles')->update([
+            'name' => DB::raw("lower(replace(`display_name`, ' ', '-'))"),
+        ]);
+    }
+}
diff --git a/database/migrations/2020_09_19_094251_add_activity_indexes.php b/database/migrations/2020_09_19_094251_add_activity_indexes.php
new file mode 100644 (file)
index 0000000..2835526
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddActivityIndexes extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->index('key');
+            $table->index('created_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->dropIndex('activities_key_index');
+            $table->dropIndex('activities_created_at_index');
+        });
+    }
+}
diff --git a/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php b/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php
new file mode 100644 (file)
index 0000000..09ee87f
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddEntitySoftDeletes extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('bookshelves', function (Blueprint $table) {
+            $table->softDeletes();
+        });
+        Schema::table('books', function (Blueprint $table) {
+            $table->softDeletes();
+        });
+        Schema::table('chapters', function (Blueprint $table) {
+            $table->softDeletes();
+        });
+        Schema::table('pages', function (Blueprint $table) {
+            $table->softDeletes();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('bookshelves', function (Blueprint $table) {
+            $table->dropSoftDeletes();
+        });
+        Schema::table('books', function (Blueprint $table) {
+            $table->dropSoftDeletes();
+        });
+        Schema::table('chapters', function (Blueprint $table) {
+            $table->dropSoftDeletes();
+        });
+        Schema::table('pages', function (Blueprint $table) {
+            $table->dropSoftDeletes();
+        });
+    }
+}
diff --git a/database/migrations/2020_09_27_210528_create_deletions_table.php b/database/migrations/2020_09_27_210528_create_deletions_table.php
new file mode 100644 (file)
index 0000000..c38a935
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateDeletionsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('deletions', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('deleted_by');
+            $table->string('deletable_type', 100);
+            $table->integer('deletable_id');
+            $table->timestamps();
+
+            $table->index('deleted_by');
+            $table->index('deletable_type');
+            $table->index('deletable_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('deletions');
+    }
+}
diff --git a/database/migrations/2020_11_07_232321_simplify_activities_table.php b/database/migrations/2020_11_07_232321_simplify_activities_table.php
new file mode 100644 (file)
index 0000000..59f13f4
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class SimplifyActivitiesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function (Blueprint $table) {
+            $table->renameColumn('key', 'type');
+            $table->renameColumn('extra', 'detail');
+            $table->dropColumn('book_id');
+            $table->integer('entity_id')->nullable()->change();
+            $table->string('entity_type', 191)->nullable()->change();
+        });
+
+        DB::table('activities')
+            ->where('entity_id', '=', 0)
+            ->update([
+                'entity_id'   => null,
+                'entity_type' => null,
+            ]);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        DB::table('activities')
+            ->whereNull('entity_id')
+            ->update([
+                'entity_id'   => 0,
+                'entity_type' => '',
+            ]);
+
+        Schema::table('activities', function (Blueprint $table) {
+            $table->renameColumn('type', 'key');
+            $table->renameColumn('detail', 'extra');
+            $table->integer('book_id');
+
+            $table->integer('entity_id')->change();
+            $table->string('entity_type', 191)->change();
+
+            $table->index('book_id');
+        });
+    }
+}
diff --git a/database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php b/database/migrations/2020_12_30_173528_add_owned_by_field_to_entities.php
new file mode 100644 (file)
index 0000000..abff390
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class AddOwnedByFieldToEntities extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        $tables = ['pages', 'books', 'chapters', 'bookshelves'];
+        foreach ($tables as $table) {
+            Schema::table($table, function (Blueprint $table) {
+                $table->integer('owned_by')->unsigned()->index();
+            });
+
+            DB::table($table)->update(['owned_by' => DB::raw('`created_by`')]);
+        }
+
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->renameColumn('created_by', 'owned_by');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        $tables = ['pages', 'books', 'chapters', 'bookshelves'];
+        foreach ($tables as $table) {
+            Schema::table($table, function (Blueprint $table) {
+                $table->dropColumn('owned_by');
+            });
+        }
+
+        Schema::table('joint_permissions', function (Blueprint $table) {
+            $table->renameColumn('owned_by', 'created_by');
+        });
+    }
+}
diff --git a/database/migrations/2021_01_30_225441_add_settings_type_column.php b/database/migrations/2021_01_30_225441_add_settings_type_column.php
new file mode 100644 (file)
index 0000000..61d9bda
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddSettingsTypeColumn extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('settings', function (Blueprint $table) {
+            $table->string('type', 50)->default('string');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('settings', function (Blueprint $table) {
+            $table->dropColumn('type');
+        });
+    }
+}
diff --git a/database/migrations/2021_03_08_215138_add_user_slug.php b/database/migrations/2021_03_08_215138_add_user_slug.php
new file mode 100644 (file)
index 0000000..dad1e42
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
+
+class AddUserSlug extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->string('slug', 180);
+        });
+
+        $slugMap = [];
+        DB::table('users')->cursor()->each(function ($user) use (&$slugMap) {
+            $userSlug = Str::slug($user->name);
+            while (isset($slugMap[$userSlug])) {
+                $userSlug = Str::slug($user->name . Str::random(4));
+            }
+            $slugMap[$userSlug] = true;
+
+            DB::table('users')
+                ->where('id', $user->id)
+                ->update(['slug' => $userSlug]);
+        });
+
+        Schema::table('users', function (Blueprint $table) {
+            $table->unique('slug');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('slug');
+        });
+    }
+}
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');
+        });
+    }
+}
index d86cb0dddd06947d478dde126f7ae8c685ec17a9..069765eb48cb36aed6610d4d73452a18b1abfb41 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-use Illuminate\Database\Seeder;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Seeder;
 
 class DatabaseSeeder extends Seeder
 {
index 6d902a19632a7e7f7983d6710550854654faaf4e..7463a3b3760ac006bb26c3e5845d3300e8f3cb67 100644 (file)
@@ -5,10 +5,10 @@ use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
 use Illuminate\Database\Seeder;
 use Illuminate\Support\Str;
 
@@ -31,12 +31,12 @@ class DummyContentSeeder extends Seeder
         $role = Role::getRole('viewer');
         $viewerUser->attachRole($role);
 
-        $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id];
+        $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
 
-        factory(\BookStack\Entities\Book::class, 5)->create($byData)
-            ->each(function($book) use ($editorUser, $byData) {
+        factory(\BookStack\Entities\Models\Book::class, 5)->create($byData)
+            ->each(function ($book) use ($byData) {
                 $chapters = factory(Chapter::class, 3)->create($byData)
-                    ->each(function($chapter) use ($editorUser, $book, $byData){
+                    ->each(function ($chapter) use ($book, $byData) {
                         $pages = factory(Page::class, 3)->make(array_merge($byData, ['book_id' => $book->id]));
                         $chapter->pages()->saveMany($pages);
                     });
@@ -45,7 +45,7 @@ class DummyContentSeeder extends Seeder
                 $book->pages()->saveMany($pages);
             });
 
-        $largeBook = factory(\BookStack\Entities\Book::class)->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+        $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->pages()->saveMany($pages);
@@ -58,15 +58,15 @@ class DummyContentSeeder extends Seeder
         $apiPermission = RolePermission::getByName('access-api');
         $editorRole->attachPermission($apiPermission);
         $token = (new ApiToken())->forceFill([
-            'user_id' => $editorUser->id,
-            'name' => 'Testing API key',
+            'user_id'    => $editorUser->id,
+            'name'       => 'Testing API key',
             'expires_at' => ApiToken::defaultExpiry(),
-            'secret' => Hash::make('password'),
-            'token_id' => 'apitoken',
+            'secret'     => Hash::make('password'),
+            'token_id'   => 'apitoken',
         ]);
         $token->save();
 
         app(PermissionService::class)->buildJointPermissions();
-        app(SearchService::class)->indexAllEntities();
+        app(SearchIndex::class)->indexAllEntities();
     }
 }
index 4db10395adf037a48aac650a19b2cc02748f3f84..535626b8f794e8c74fb3d28a8b0bfe6ce7612975 100644 (file)
@@ -3,9 +3,9 @@
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
 use Illuminate\Database\Seeder;
 use Illuminate\Support\Str;
 
@@ -23,12 +23,12 @@ class LargeContentSeeder extends Seeder
         $editorRole = Role::getRole('editor');
         $editorUser->attachRole($editorRole);
 
-        $largeBook = factory(\BookStack\Entities\Book::class)->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+        $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(SearchService::class)->indexAllEntities();
+        app(SearchIndex::class)->indexAllEntities();
     }
 }
diff --git a/dev/api/requests/pages-create.json b/dev/api/requests/pages-create.json
new file mode 100644 (file)
index 0000000..1f53b42
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "book_id": 1,
+       "name": "My API Page",
+       "html": "<p>my new API page</p>",
+       "tags": [
+               {"name": "Category", "value": "Not Bad Content"},
+               {"name": "Rating", "value": "Average"}
+       ]
+}
\ No newline at end of file
diff --git a/dev/api/requests/pages-update.json b/dev/api/requests/pages-update.json
new file mode 100644 (file)
index 0000000..b9bfeb6
--- /dev/null
@@ -0,0 +1,9 @@
+{
+       "chapter_id": 1,
+       "name": "My updated API Page",
+       "html": "<p>my new API page - Updated</p>",
+       "tags": [
+               {"name": "Category", "value": "API Examples"},
+               {"name": "Rating", "value": "Alright"}
+       ]
+}
\ No newline at end of file
index 0b4336ab2a2b846950c5a01367161650b94da449..124305c8cd3527fca0c3117d78005d0fd989e93a 100644 (file)
@@ -3,6 +3,7 @@
   "description": "This is a book created via the API",
   "created_by": 1,
   "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",
index 29e83b1c024279f823bb6295d3238645a7384dc6..9900b5b0445a3d157237ec95830ccf735672b00e 100644 (file)
@@ -9,6 +9,7 @@
       "updated_at": "2019-12-11 20:57:31",
       "created_by": 1,
       "updated_by": 1,
+      "owned_by": 1,
       "image_id": 3
     },
     {
@@ -20,6 +21,7 @@
       "updated_at": "2019-12-11 20:57:23",
       "created_by": 4,
       "updated_by": 3,
+      "owned_by": 3,
       "image_id": 34
     }
   ],
index 2e43f5f87fc810163bc8323f53e304c6cbb070db..0b0bce4e8f91571e443063623766ee61c84efb55 100644 (file)
     "id": 1,
     "name": "Admin"
   },
+  "owned_by": {
+    "id": 1,
+    "name": "Admin"
+  },
   "tags": [
     {
       "id": 13,
-      "entity_id": 16,
-      "entity_type": "BookStack\\Book",
       "name": "Category",
       "value": "Guide",
-      "order": 0,
-      "created_at": "2020-01-12 14:11:51",
-      "updated_at": "2020-01-12 14:11:51"
+      "order": 0
     }
   ],
   "cover": {
index 8f20b5b9f545c92a17a52c6bc7d1f7de60ef9c12..fd93dc9aef1a3045ed874be2c9451adc7074b910 100644 (file)
@@ -7,5 +7,6 @@
   "updated_at": "2020-01-12 14:16:10",
   "created_by": 1,
   "updated_by": 1,
+  "owned_by": 1,
   "image_id": 452
 }
\ No newline at end of file
index 7aac2768789786f192c84b41f8c07e98dff82487..a990f278bac30e02c907e824c3a66841902b5bb2 100644 (file)
@@ -5,6 +5,7 @@
   "description": "This is a great new chapter that I've created via the API",
   "created_by": 1,
   "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",
index 0c1fc5fc2a6f46992a3fd1ad7963608a5f5239b3..72ed7534df2eba47e16fa883dde9d216cb37c809 100644 (file)
@@ -10,7 +10,8 @@
       "created_at": "2019-05-05 21:49:56",
       "updated_at": "2019-09-28 11:24:23",
       "created_by": 1,
-      "updated_by": 1
+      "updated_by": 1,
+      "owned_by": 1
     },
     {
       "id": 2,
@@ -22,7 +23,8 @@
       "created_at": "2019-05-05 21:58:07",
       "updated_at": "2019-10-17 15:05:34",
       "created_by": 3,
-      "updated_by": 3
+      "updated_by": 3,
+      "owned_by": 3
     }
   ],
   "total": 40
index 2eddad8955070a36b15df967df96a3b7e7aa7dd7..41fed80efc1a3786ca4919c9d8d33a7937e2197a 100644 (file)
     "id": 1,
     "name": "Admin"
   },
+  "owned_by": {
+    "id": 1,
+    "name": "Admin"
+  },
   "tags": [
     {
       "name": "Category",
       "value": "Guide",
-      "order": 0,
-      "created_at": "2020-05-22 22:51:51",
-      "updated_at": "2020-05-22 22:51:51"
+      "order": 0
     }
   ],
   "pages": [
@@ -36,9 +38,9 @@
       "updated_at": "2019-08-26 14:32:59",
       "created_by": 1,
       "updated_by": 1,
-      "draft": 0,
+      "draft": false,
       "revision_count": 2,
-      "template": 0
+      "template": false
     },
     {
       "id": 7,
@@ -51,9 +53,9 @@
       "updated_at": "2019-06-06 12:03:04",
       "created_by": 3,
       "updated_by": 3,
-      "draft": 0,
+      "draft": false,
       "revision_count": 1,
-      "template": 0
+      "template": false
     }
   ]
 }
\ No newline at end of file
index a7edb15b05411a389448deb10fc2c720dd43dcb0..11dedd0ca897f6efd4ebbc207e3ed988ba418ca3 100644 (file)
@@ -9,6 +9,7 @@
   "updated_at": "2020-05-22 23:07:20",
   "created_by": 1,
   "updated_by": 1,
+  "owned_by": 1,
   "book": {
     "id": 1,
     "name": "BookStack User Guide",
diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json
new file mode 100644 (file)
index 0000000..0b19fb4
--- /dev/null
@@ -0,0 +1,39 @@
+{
+       "id": 358,
+       "book_id": 1,
+       "chapter_id": 0,
+       "name": "My API Page",
+       "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_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "updated_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "owned_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "draft": false,
+       "markdown": "",
+       "revision_count": 1,
+       "template": false,
+       "tags": [
+               {
+                       "name": "Category",
+                       "value": "Not Bad Content",
+                       "order": 0
+               },
+               {
+                       "name": "Rating",
+                       "value": "Average",
+                       "order": 1
+               }
+       ]
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-list.json b/dev/api/responses/pages-list.json
new file mode 100644 (file)
index 0000000..9c162c6
--- /dev/null
@@ -0,0 +1,50 @@
+{
+       "data": [
+               {
+                       "id": 1,
+                       "book_id": 1,
+                       "chapter_id": 1,
+                       "name": "How to create page content",
+                       "slug": "how-to-create-page-content",
+                       "priority": 0,
+                       "draft": false,
+                       "template": false,
+                       "created_at": "2019-05-05 21:49:58",
+                       "updated_at": "2020-07-04 15:50:58",
+                       "created_by": 1,
+                       "updated_by": 1,
+                       "owned_by": 1
+               },
+               {
+                       "id": 2,
+                       "book_id": 1,
+                       "chapter_id": 1,
+                       "name": "How to use images",
+                       "slug": "how-to-use-images",
+                       "priority": 2,
+                       "draft": false,
+                       "template": false,
+                       "created_at": "2019-05-05 21:53:30",
+                       "updated_at": "2019-06-06 12:03:04",
+                       "created_by": 1,
+                       "updated_by": 1,
+                       "owned_by": 1
+               },
+               {
+                       "id": 3,
+                       "book_id": 1,
+                       "chapter_id": 1,
+                       "name": "Drawings via draw.io",
+                       "slug": "drawings-via-drawio",
+                       "priority": 3,
+                       "draft": false,
+                       "template": false,
+                       "created_at": "2019-05-05 21:53:49",
+                       "updated_at": "2019-12-18 21:56:52",
+                       "created_by": 1,
+                       "updated_by": 1,
+                       "owned_by": 1
+               }
+       ],
+       "total": 322
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json
new file mode 100644 (file)
index 0000000..93f7770
--- /dev/null
@@ -0,0 +1,39 @@
+{
+       "id": 306,
+       "book_id": 1,
+       "chapter_id": 0,
+       "name": "A page written in markdown",
+       "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_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "updated_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "owned_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "draft": false,
+       "markdown": "# How this is built\r\n\r\nThis page is written in markdown. BookStack stores the page data in HTML.\r\n\r\nHere's a cute picture of my cat:\r\n\r\n[![yXSrubes.jpg](http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg)](http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg)",
+       "revision_count": 5,
+       "template": false,
+       "tags": [
+               {
+                       "name": "Category",
+                       "value": "Top Content",
+                       "order": 0
+               },
+               {
+                       "name": "Animal",
+                       "value": "Cat",
+                       "order": 1
+               }
+       ]
+}
\ No newline at end of file
diff --git a/dev/api/responses/pages-update.json b/dev/api/responses/pages-update.json
new file mode 100644 (file)
index 0000000..ae5c0ea
--- /dev/null
@@ -0,0 +1,39 @@
+{
+       "id": 361,
+       "book_id": 1,
+       "chapter_id": 1,
+       "name": "My updated API Page",
+       "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_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "updated_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "owned_by": {
+               "id": 1,
+               "name": "Admin"
+       },
+       "draft": false,
+       "markdown": "",
+       "revision_count": 5,
+       "template": false,
+       "tags": [
+               {
+                       "name": "Category",
+                       "value": "API Examples",
+                       "order": 0
+               },
+               {
+                       "name": "Rating",
+                       "value": "Alright",
+                       "order": 0
+               }
+       ]
+}
\ No newline at end of file
index 64f3c7f5394b657933ce64c1b793173f3b84dbbc..fafa4c9cd1e64f82bf4e81db8fbcf7aca256b620 100644 (file)
@@ -3,6 +3,7 @@
   "description": "This is my shelf with some books",
   "created_by": 1,
   "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",
index bccd08626176c84262f62477b4410ac802788b30..f5e9d03bb6f10ba6df1ed1677782ca788c7bc0a6 100644 (file)
@@ -9,6 +9,7 @@
       "updated_at": "2020-04-10 13:00:45",
       "created_by": 4,
       "updated_by": 1,
+      "owned_by": 1,
       "image_id": 31
     },
     {
@@ -20,6 +21,7 @@
       "updated_at": "2020-04-10 13:00:58",
       "created_by": 4,
       "updated_by": 1,
+      "owned_by": 1,
       "image_id": 28
     },
     {
@@ -31,6 +33,7 @@
       "updated_at": "2020-04-10 13:00:53",
       "created_by": 4,
       "updated_by": 1,
+      "owned_by": 4,
       "image_id": 30
     }
   ],
index 634fbb5a53c6fde235e72c2516b111a73f645451..d663e82c5fe2bd074c9ee1e7c24f87869094d35f 100644 (file)
     "id": 1,
     "name": "Admin"
   },
+  "owned_by": {
+    "id": 1,
+    "name": "Admin"
+  },
   "created_at": "2020-04-10 13:24:09",
   "updated_at": "2020-04-10 13:31:04",
   "tags": [
     {
       "id": 16,
-      "entity_id": 14,
-      "entity_type": "BookStack\\Bookshelf",
       "name": "Category",
       "value": "Guide",
-      "order": 0,
-      "created_at": "2020-04-10 13:31:04",
-      "updated_at": "2020-04-10 13:31:04"
+      "order": 0
     }
   ],
   "cover": {
index 4820150eb0afdc6c76a3b81bde2105a8858d7bb2..4bde44b54d5f2e2a188788d306fae0b0cdede022 100644 (file)
@@ -5,6 +5,7 @@
   "description": "This is my update shelf with some books",
   "created_by": 1,
   "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"
index 8816615cf69986b75a3713d02e80b5a283221d13..178ea9a6c865481f40d07529de0915d86553152e 100644 (file)
@@ -1,16 +1,23 @@
-FROM php:7.3-apache
+FROM php:7.4-apache
 
 ENV APACHE_DOCUMENT_ROOT /app/public
 WORKDIR /app
 
+# Install additional dependacnies and configure apache
 RUN apt-get update -y \
-    && apt-get install -y git zip unzip libtidy-dev libpng-dev libldap2-dev libxml++2.6-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 pdo_mysql tidy dom xml mbstring gd ldap \
+    && docker-php-ext-install pdo_mysql gd ldap zip \
     && 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 \
-    && php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
+    && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
+
+# Install composer
+RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
     && php composer-setup.php \
     && mv composer.phar /usr/bin/composer \
     && php -r "unlink('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"
index ff44f0c8d3f5d7b3af67a55818022805d8d113cf..e91d34a713377a7e579a6594000c01fb5c98ba57 100755 (executable)
@@ -7,8 +7,9 @@ env
 if [[ -n "$1" ]]; then
     exec "$@"
 else
+    composer install
     wait-for-it db:3306 -t 45
     php artisan migrate --database=mysql
     chown -R www-data:www-data storage
     exec apache2-foreground
-fi
\ No newline at end of file
+fi
index e59e1e8a027b55f73a7466812754692a468d9185..a8f33fd3d93c2be93d34f5c3bb4b01f7578815de 100755 (executable)
@@ -5,4 +5,4 @@ set -e
 npm install
 npm rebuild node-sass
 
-exec npm run watch
\ No newline at end of file
+SHELL=/bin/sh exec npm run watch
diff --git a/dev/docker/init.db/01.sql b/dev/docker/init.db/01.sql
new file mode 100644 (file)
index 0000000..2536c3f
--- /dev/null
@@ -0,0 +1,5 @@
+# create test database
+CREATE DATABASE IF NOT EXISTS `bookstack-test`;
+
+# grant rights
+GRANT ALL PRIVILEGES ON `bookstack-test`.* TO 'bookstack-test'@'%';
index ac0e929cdfe416a4454abf6ffa02929232a15c79..832765dd6a3ef9919d97bb355a488c5fb73d0970 100644 (file)
@@ -59,4 +59,41 @@ Will result with `this.$opts` being:
     "delay": "500",
     "show": ""  
 }
+```
+
+#### Global Helpers
+
+There are various global helper libraries which can be used in components:
+
+```js
+// HTTP service
+window.$http.get(url, params);
+window.$http.post(url, data);
+window.$http.put(url, data);
+window.$http.delete(url, data);
+window.$http.patch(url, data);
+
+// Global event system
+// Emit a global event
+window.$events.emit(eventName, eventData);
+// Listen to a global event
+window.$events.listen(eventName, callback);
+// Show a success message
+window.$events.success(message);
+// Show an error message
+window.$events.error(message);
+// Show validation errors, if existing, as an error notification
+window.$events.showValidationErrors(error);
+
+// Translator
+// Take the given plural text and count to decide on what plural option
+// to use, Similar to laravel's trans_choice function but instead
+// takes the direction directly instead of a translation key.
+window.trans_plural(translationString, count, replacements);
+
+// Component System
+// Parse and initialise any components from the given root el down.
+window.components.init(rootEl);
+// Get the first active component of the given name
+window.components.first(name);
 ```
\ No newline at end of file
diff --git a/dev/docs/logical-theme-system.md b/dev/docs/logical-theme-system.md
new file mode 100644 (file)
index 0000000..b950d7d
--- /dev/null
@@ -0,0 +1,112 @@
+# Logical Theme System
+
+BookStack allows logical customization via the theme system which enables you to add, or extend, functionality within the PHP side of the system without needing to alter the core application files.
+
+WARNING: This system is currently in alpha so may incur changes. Once we've gathered some feedback on usage we'll look to removing this warning. This system will be considered semi-stable in the future. The `Theme::` system will be kept maintained but specific customizations or deeper app/framework usage using this system will not be supported nor considered in any way stable. Customizations using this system should be checked after updates.
+
+## Getting Started
+
+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`.
+
+Within your theme folder create a `functions.php` file. BookStack will look for this and run it during app boot-up. Within this file you can use the `Theme` facade API, described below, to hook into certain app events.
+
+## `Theme` Facade API
+
+Below details the public methods of the `Theme` facade that are considered stable:
+
+### `Theme::listen`
+
+This method listens to a system event and runs the given action when that event occurs. The arguments passed to the action depend on the event. Event names are exposed as static properties on the `\BookStack\Theming\ThemeEvents` class. 
+
+It is possible to listen to a single event using multiple actions. When dispatched, BookStack will loop over and run each action for that event.
+If an action returns a non-null value then BookStack will stop cycling through actions at that point and make use of the non-null return value if possible (Depending on the event).
+
+**Arguments**
+- string $event
+- callable $action
+
+**Example**
+
+```php
+Theme::listen(
+    \BookStack\Theming\ThemeEvents::AUTH_LOGIN,
+    function($service, $user) {
+        \Log::info("Login by {$user->name} via {$service}");
+    }
+);
+```
+
+### `Theme::addSocialDriver`
+
+This method allows you to register a custom social authentication driver within the system. This is primarily intended to use with [Socialite Providers](https://socialiteproviders.com/).
+
+**Arguments**
+- string $driverName
+- array $config
+- string $socialiteHandler
+
+**Example**
+
+*See "Custom Socialite Service Example" below.*
+
+## 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).
+
+The comments above each constant with the `ThemeEvents.php` file describe the dispatch conditions of the event, in addition to the arguments the action will receive. The comments may also describe any ways the return value of the action may be used. 
+
+## Example `functions.php` file
+
+```php
+<?php
+
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
+
+// Logs custom message on user login
+Theme::listen(ThemeEvents::AUTH_LOGIN, function($method, $user) {
+    Log::info("Login via {$method} for {$user->name}");
+});
+
+// Adds a `/info` public URL endpoint that emits php debug details
+Theme::listen(ThemeEvents::APP_BOOT, function($app) {
+    \Route::get('info', function() {
+        phpinfo(); // Don't do this on a production instance!
+    });
+});
+```
+
+## Custom Socialite Service Example
+
+The below shows an example of adding a custom reddit socialite service to BookStack. 
+BookStack exposes a helper function for this via `Theme::addSocialDriver` which sets the required config and event listeners in the platform.
+
+The require statements reference composer installed dependencies within the theme folder. They are required manually since they are not auto-loaded like other app files due to being outside the main BookStack dependency list. 
+
+```php
+require "vendor/socialiteproviders/reddit/Provider.php";
+require "vendor/socialiteproviders/reddit/RedditExtendSocialite.php";
+
+Theme::listen(ThemeEvents::APP_BOOT, function($app) {
+    Theme::addSocialDriver('reddit', [
+        'client_id' => 'abc123',
+        'client_secret' => 'def456789',
+        '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
diff --git a/dev/docs/visual-theme-system.md b/dev/docs/visual-theme-system.md
new file mode 100644 (file)
index 0000000..058bd28
--- /dev/null
@@ -0,0 +1,31 @@
+# Visual Theme System
+
+BookStack allows visual customization via the theme system which enables you to extensively customize views, translation text & icons.
+
+This theme system itself is maintained and supported but usages of this system, including the files you are able to override, are not considered stable and may change upon any update. You should test any customizations made after updates.
+
+## Getting Started
+
+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`.
+
+## Customizing View Files
+
+Content placed in your `themes/<theme_name>/` folder will override the original view files found in the `resources/views` folder. These files are typically [Laravel Blade](https://laravel.com/docs/6.x/blade) files.
+
+## Customizing Icons
+
+SVG files placed in a `themes/<theme_name>/icons` folder will override any icons of the same name within `resources/icons`. You'd typically want to follow the format convention of the existing icons, where no XML deceleration is included and no width & height attributes are set, to ensure optimal compatibility. 
+
+## Customizing Text Content
+
+Folders with PHP translation files placed in a `themes/<theme_name>/lang` folder will override translations defined within the `resources/lang` folder. Custom translations are merged with the original files so you only need to override the select translations you want to override, you don't need to copy the whole original file. Note that you'll need to include the language folder.
+
+As an example, Say I wanted to change 'Search' to 'Find'; Within a `themes/<theme_name>/lang/en/common.php` file I'd set the following:
+
+```php
+<?php
+return [
+    'search' => 'find',
+];
+```
\ No newline at end of file
index ea7a61ab554aea7540a28655d0e9c61822550f8a..7920258944694e6b0d1af757ba9fd3bef5b45e57 100644 (file)
@@ -10,24 +10,27 @@ services:
   db:
     image: mysql:8
     environment:
-      MYSQL_DATABASE: bookstack-test
+      MYSQL_DATABASE: bookstack-dev
       MYSQL_USER: bookstack-test
       MYSQL_PASSWORD: bookstack-test
       MYSQL_RANDOM_ROOT_PASSWORD: 'true'
     command: --default-authentication-plugin=mysql_native_password
     volumes:
+      - ./dev/docker/init.db:/docker-entrypoint-initdb.d
       - db:/var/lib/mysql
   app:
     build:
       context: .
       dockerfile: ./dev/docker/Dockerfile
     environment:
+      APP_URL: http://localhost:${DEV_PORT:-8080}
       DB_CONNECTION: mysql
       DB_HOST: db
       DB_PORT: 3306
-      DB_DATABASE: bookstack-test
+      DB_DATABASE: bookstack-dev
       DB_USERNAME: bookstack-test
       DB_PASSWORD: bookstack-test
+      TEST_DATABASE_URL: mysql://bookstack-test:bookstack-test@db/bookstack-test
       MAIL_DRIVER: smtp
       MAIL_HOST: mailhog
       MAIL_PORT: 1025
@@ -39,6 +42,7 @@ services:
   node:
     image: node:alpine
     working_dir: /app
+    user: node
     volumes:
       - ./:/app
     entrypoint: /app/dev/docker/entrypoint.node.sh
index b601736835f32014f9af20ca3b553d0de66f9e8e..97d4d82871afc800db0b9508962ad376956d96f9 100644 (file)
 {
+  "name": "bookstack",
+  "lockfileVersion": 2,
   "requires": true,
-  "lockfileVersion": 1,
-  "dependencies": {
-    "@webassemblyjs/ast": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
-      "integrity": "sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==",
-      "dev": true,
-      "requires": {
-        "@webassemblyjs/helper-module-context": "1.9.0",
-        "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
-        "@webassemblyjs/wast-parser": "1.9.0"
+  "packages": {
+    "": {
+      "dependencies": {
+        "clipboard": "^2.0.8",
+        "codemirror": "^5.62.3",
+        "dropzone": "^5.9.2",
+        "markdown-it": "^12.2.0",
+        "markdown-it-task-lists": "^2.1.1",
+        "sortablejs": "^1.14.0"
+      },
+      "devDependencies": {
+        "chokidar-cli": "^3.0.0",
+        "esbuild": "0.12.22",
+        "livereload": "^0.9.3",
+        "npm-run-all": "^4.1.5",
+        "punycode": "^2.1.1",
+        "sass": "^1.38.0"
       }
     },
-    "@webassemblyjs/floating-point-hex-parser": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz",
-      "integrity": "sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==",
-      "dev": true
-    },
-    "@webassemblyjs/helper-api-error": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz",
-      "integrity": "sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==",
-      "dev": true
-    },
-    "@webassemblyjs/helper-buffer": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz",
-      "integrity": "sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==",
-      "dev": true
-    },
-    "@webassemblyjs/helper-code-frame": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz",
-      "integrity": "sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==",
+    "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,
-      "requires": {
-        "@webassemblyjs/wast-printer": "1.9.0"
+      "engines": {
+        "node": ">=6"
       }
     },
-    "@webassemblyjs/helper-fsm": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz",
-      "integrity": "sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==",
-      "dev": true
+    "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"
+      }
     },
-    "@webassemblyjs/helper-module-context": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz",
-      "integrity": "sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==",
+    "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,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0"
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
       }
     },
-    "@webassemblyjs/helper-wasm-bytecode": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz",
-      "integrity": "sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==",
+    "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.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
       "dev": true
     },
-    "@webassemblyjs/helper-wasm-section": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz",
-      "integrity": "sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==",
+    "node_modules/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==",
       "dev": true,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/helper-buffer": "1.9.0",
-        "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
-        "@webassemblyjs/wasm-gen": "1.9.0"
+      "engines": {
+        "node": ">=8"
       }
     },
-    "@webassemblyjs/ieee754": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz",
-      "integrity": "sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==",
+    "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,
-      "requires": {
-        "@xtuc/ieee754": "^1.2.0"
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
       }
     },
-    "@webassemblyjs/leb128": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.9.0.tgz",
-      "integrity": "sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==",
+    "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,
-      "requires": {
-        "@xtuc/long": "4.2.2"
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
       }
     },
-    "@webassemblyjs/utf8": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.9.0.tgz",
-      "integrity": "sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==",
-      "dev": true
-    },
-    "@webassemblyjs/wasm-edit": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz",
-      "integrity": "sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==",
+    "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,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/helper-buffer": "1.9.0",
-        "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
-        "@webassemblyjs/helper-wasm-section": "1.9.0",
-        "@webassemblyjs/wasm-gen": "1.9.0",
-        "@webassemblyjs/wasm-opt": "1.9.0",
-        "@webassemblyjs/wasm-parser": "1.9.0",
-        "@webassemblyjs/wast-printer": "1.9.0"
+      "engines": {
+        "node": ">=6"
       }
     },
-    "@webassemblyjs/wasm-gen": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz",
-      "integrity": "sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==",
+    "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,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
-        "@webassemblyjs/ieee754": "1.9.0",
-        "@webassemblyjs/leb128": "1.9.0",
-        "@webassemblyjs/utf8": "1.9.0"
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
       }
     },
-    "@webassemblyjs/wasm-opt": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz",
-      "integrity": "sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==",
+    "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,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/helper-buffer": "1.9.0",
-        "@webassemblyjs/wasm-gen": "1.9.0",
-        "@webassemblyjs/wasm-parser": "1.9.0"
+      "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"
       }
     },
-    "@webassemblyjs/wasm-parser": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz",
-      "integrity": "sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==",
+    "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,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/helper-api-error": "1.9.0",
-        "@webassemblyjs/helper-wasm-bytecode": "1.9.0",
-        "@webassemblyjs/ieee754": "1.9.0",
-        "@webassemblyjs/leb128": "1.9.0",
-        "@webassemblyjs/utf8": "1.9.0"
+      "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"
       }
     },
-    "@webassemblyjs/wast-parser": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz",
-      "integrity": "sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==",
+    "node_modules/cliui": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
+      "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
       "dev": true,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/floating-point-hex-parser": "1.9.0",
-        "@webassemblyjs/helper-api-error": "1.9.0",
-        "@webassemblyjs/helper-code-frame": "1.9.0",
-        "@webassemblyjs/helper-fsm": "1.9.0",
-        "@xtuc/long": "4.2.2"
+      "dependencies": {
+        "string-width": "^3.1.0",
+        "strip-ansi": "^5.2.0",
+        "wrap-ansi": "^5.1.0"
       }
     },
-    "@webassemblyjs/wast-printer": {
-      "version": "1.9.0",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz",
-      "integrity": "sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==",
+    "node_modules/codemirror": {
+      "version": "5.62.3",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.3.tgz",
+      "integrity": "sha512-zZAyOfN8TU67ngqrxhOgtkSAGV9jSpN1snbl8elPtnh9Z5A11daR405+dhLzLnuXrwX0WCShWlybxPN3QC/9Pg=="
+    },
+    "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,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/wast-parser": "1.9.0",
-        "@xtuc/long": "4.2.2"
+      "dependencies": {
+        "color-name": "1.1.3"
       }
     },
-    "@xtuc/ieee754": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
-      "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+    "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
     },
-    "@xtuc/long": {
-      "version": "4.2.2",
-      "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
-      "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+    "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
     },
-    "acorn": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz",
-      "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==",
-      "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"
+      }
     },
-    "ajv": {
-      "version": "6.12.2",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz",
-      "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==",
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
       "dev": true,
-      "requires": {
-        "fast-deep-equal": "^3.1.1",
-        "fast-json-stable-stringify": "^2.0.0",
-        "json-schema-traverse": "^0.4.1",
-        "uri-js": "^4.2.2"
+      "engines": {
+        "node": ">=0.10.0"
       }
     },
-    "ajv-errors": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
-      "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
-      "dev": true
+    "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"
+      }
     },
-    "ajv-keywords": {
-      "version": "3.5.0",
-      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.0.tgz",
-      "integrity": "sha512-eyoaac3btgU8eJlvh01En8OCKzRqlLe2G5jDsCr3RiE2uLGMEEB1aaGwVVpwR8M95956tGH6R+9edC++OvzaVw==",
-      "dev": true
+    "node_modules/delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
     },
-    "ansi-regex": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-      "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+    "node_modules/dropzone": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.2.tgz",
+      "integrity": "sha512-5t2z51DzIsWDbTpwcJIvUlwxBbvcwdCApz0yb9ecKJwG155Xm92KMEZmHW1B0MzoXOKvFwdd0nPu5cpeVcvPHQ=="
+    },
+    "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
     },
-    "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,
-      "requires": {
-        "color-convert": "^1.9.0"
+    "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"
       }
     },
-    "anymatch": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
-      "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
+    "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,
-      "requires": {
-        "normalize-path": "^3.0.0",
-        "picomatch": "^2.0.4"
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
       }
     },
-    "aproba": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
-      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
-      "dev": true
-    },
-    "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"
+    "node_modules/es-abstract": {
+      "version": "1.17.6",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz",
+      "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==",
+      "dev": true,
+      "dependencies": {
+        "es-to-primitive": "^1.2.1",
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1",
+        "is-callable": "^1.2.0",
+        "is-regex": "^1.1.0",
+        "object-inspect": "^1.7.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.0",
+        "string.prototype.trimend": "^1.0.1",
+        "string.prototype.trimstart": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
       }
     },
-    "arr-diff": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
-      "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=",
-      "dev": true
+    "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"
+      }
     },
-    "arr-flatten": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
-      "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==",
-      "dev": true
+    "node_modules/esbuild": {
+      "version": "0.12.22",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.22.tgz",
+      "integrity": "sha512-yWCr9RoFehpqoe/+MwZXJpYOEIt7KOEvNnjIeMZpMSyQt+KCBASM3y7yViiN5dJRphf1wGdUz1+M4rTtWd/ulA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      }
     },
-    "arr-union": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
-      "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=",
-      "dev": true
+    "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"
+      }
     },
-    "array-filter": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz",
-      "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=",
-      "dev": true
+    "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"
+      }
     },
-    "array-map": {
-      "version": "0.0.0",
-      "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz",
-      "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=",
-      "dev": true
+    "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"
+      }
     },
-    "array-reduce": {
-      "version": "0.0.0",
-      "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz",
-      "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=",
-      "dev": true
+    "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"
+      }
     },
-    "array-unique": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
-      "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=",
+    "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
     },
-    "asn1.js": {
-      "version": "4.10.1",
-      "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
-      "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
+    "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,
-      "requires": {
-        "bn.js": "^4.0.0",
-        "inherits": "^2.0.1",
-        "minimalistic-assert": "^1.0.0"
-      },
-      "dependencies": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
       }
     },
-    "assert": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz",
-      "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==",
+    "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,
-      "requires": {
-        "object-assign": "^4.1.1",
-        "util": "0.10.3"
+      "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": {
-        "inherits": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
-          "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=",
-          "dev": true
-        },
-        "util": {
-          "version": "0.10.3",
-          "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
-          "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=",
-          "dev": true,
-          "requires": {
-            "inherits": "2.0.1"
-          }
-        }
+        "delegate": "^3.1.2"
       }
     },
-    "assign-symbols": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
-      "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=",
+    "node_modules/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==",
       "dev": true
     },
-    "async-each": {
+    "node_modules/has": {
       "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
-      "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
       "dev": true,
-      "optional": true
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "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"
+      }
     },
-    "async-limiter": {
+    "node_modules/has-symbols": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
-      "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
+      "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
     },
-    "atob": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
-      "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+    "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
     },
-    "balanced-match": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
-      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
+    "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
     },
-    "base": {
-      "version": "0.11.2",
-      "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
-      "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==",
-      "dev": true,
-      "requires": {
-        "cache-base": "^1.0.1",
-        "class-utils": "^0.3.5",
-        "component-emitter": "^1.2.1",
-        "define-property": "^1.0.0",
-        "isobject": "^3.0.1",
-        "mixin-deep": "^1.2.0",
-        "pascalcase": "^0.1.1"
+    "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-callable": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz",
+      "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/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,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "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.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "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-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==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.1"
       },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/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==",
+      "dev": true,
       "dependencies": {
-        "define-property": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
-          "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^1.0.0"
-          }
-        },
-        "is-accessor-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
-          "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-data-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
-          "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-descriptor": {
-          "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
-          "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
-          "dev": true,
-          "requires": {
-            "is-accessor-descriptor": "^1.0.0",
-            "is-data-descriptor": "^1.0.0",
-            "kind-of": "^6.0.2"
-          }
-        }
+        "has-symbols": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
       }
     },
-    "base64-js": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
-      "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==",
+    "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
     },
-    "big.js": {
-      "version": "5.2.2",
-      "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
-      "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+    "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
     },
-    "binary-extensions": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
-      "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==",
+    "node_modules/linkify-it": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz",
+      "integrity": "sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==",
+      "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
     },
-    "bluebird": {
-      "version": "3.7.2",
-      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
-      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+    "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
     },
-    "bn.js": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.1.2.tgz",
-      "integrity": "sha512-40rZaf3bUNKTVYu9sIeeEGOg7g14Yvnj9kH7b50EiwX0Q7A6umbvfI5tvHaOERH0XigqKkfLkFQxzb4e6CIXnA==",
+    "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
     },
-    "brace-expansion": {
-      "version": "1.1.8",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz",
-      "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=",
+    "node_modules/markdown-it": {
+      "version": "12.2.0",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.2.0.tgz",
+      "integrity": "sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg==",
+      "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,
-      "requires": {
-        "balanced-match": "^1.0.0",
-        "concat-map": "0.0.1"
+      "engines": {
+        "node": ">= 0.10.0"
       }
     },
-    "braces": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
-      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+    "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,
-      "requires": {
-        "fill-range": "^7.0.1"
+      "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.8.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz",
+      "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==",
+      "dev": true
+    },
+    "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.0",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
+      "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
+      "dev": true,
+      "dependencies": {
+        "define-properties": "^1.1.2",
+        "function-bind": "^1.1.1",
+        "has-symbols": "^1.0.0",
+        "object-keys": "^1.0.11"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "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"
+      }
+    },
+    "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.2.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
+      "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
       }
     },
-    "brorand": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
-      "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
+    "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.17.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
+      "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
+      "dev": true,
+      "dependencies": {
+        "path-parse": "^1.0.6"
+      }
+    },
+    "node_modules/sass": {
+      "version": "1.38.0",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.38.0.tgz",
+      "integrity": "sha512-WBccZeMigAGKoI+NgD7Adh0ab1HUq+6BmyBUEaGxtErbUtWUevEbdgo5EZiJQofLUGcKtlNaO2IdN73AHEua5g==",
+      "dev": true,
+      "dependencies": {
+        "chokidar": ">=3.0.0 <4.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
     },
-    "browserify-aes": {
+    "node_modules/shebang-command": {
       "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
-      "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+      "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
       "dev": true,
-      "requires": {
-        "buffer-xor": "^1.0.3",
-        "cipher-base": "^1.0.0",
-        "create-hash": "^1.1.0",
-        "evp_bytestokey": "^1.0.3",
-        "inherits": "^2.0.1",
-        "safe-buffer": "^5.0.1"
+      "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.2",
+      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz",
+      "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==",
+      "dev": true
+    },
+    "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/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.5",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz",
+      "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==",
+      "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.0",
+      "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.0.tgz",
+      "integrity": "sha512-3aIv8Ffdp8EZj8iLwREGpQaUZiPyrWrpzMBHvkiSW/bK/EGve9np07Vwy7IJ5waydpGXzQZu/F8Oze2/IWkBaA==",
+      "dev": true,
+      "dependencies": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.0-next.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
       }
     },
-    "browserify-cipher": {
+    "node_modules/string.prototype.trimend": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
-      "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
+      "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
       "dev": true,
-      "requires": {
-        "browserify-aes": "^1.0.4",
-        "browserify-des": "^1.0.0",
-        "evp_bytestokey": "^1.0.0"
+      "dependencies": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.5"
       }
     },
-    "browserify-des": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
-      "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+    "node_modules/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==",
       "dev": true,
-      "requires": {
-        "cipher-base": "^1.0.1",
-        "des.js": "^1.0.0",
-        "inherits": "^2.0.1",
-        "safe-buffer": "^5.1.2"
+      "dependencies": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.5"
       }
     },
-    "browserify-rsa": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
-      "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
+    "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,
-      "requires": {
-        "bn.js": "^4.1.0",
-        "randombytes": "^2.0.1"
+      "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": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
       }
     },
-    "browserify-sign": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.0.tgz",
-      "integrity": "sha512-hEZC1KEeYuoHRqhGhTy6gWrpJA3ZDjFWv0DE61643ZnOXAKJb3u7yWcrU0mMc9SwAqK1n7myPGndkp0dFG7NFA==",
+    "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,
-      "requires": {
-        "bn.js": "^5.1.1",
-        "browserify-rsa": "^4.0.1",
-        "create-hash": "^1.2.0",
-        "create-hmac": "^1.1.7",
-        "elliptic": "^6.5.2",
-        "inherits": "^2.0.4",
-        "parse-asn1": "^5.1.5",
-        "readable-stream": "^3.6.0",
-        "safe-buffer": "^5.2.0"
+      "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/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": {
-        "readable-stream": {
-          "version": "3.6.0",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
-          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
-          "dev": true,
-          "requires": {
-            "inherits": "^2.0.3",
-            "string_decoder": "^1.1.1",
-            "util-deprecate": "^1.0.1"
-          }
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "which": "bin/which"
+      }
+    },
+    "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.4.6",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
+      "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.3.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": "^5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
         },
-        "safe-buffer": {
-          "version": "5.2.1",
-          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
-          "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
-          "dev": true
+        "utf-8-validate": {
+          "optional": true
         }
       }
     },
-    "browserify-zlib": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
-      "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+    "node_modules/y18n": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz",
+      "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
+      "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,
-      "requires": {
-        "pako": "~1.0.5"
+      "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"
       }
     },
-    "buffer": {
-      "version": "4.9.2",
-      "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
-      "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
+    "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",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+      "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+      "dev": true
+    },
+    "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,
       "requires": {
-        "base64-js": "^1.0.2",
-        "ieee754": "^1.1.4",
-        "isarray": "^1.0.0"
+        "color-convert": "^1.9.0"
       }
     },
-    "buffer-from": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
-      "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
-      "dev": true
+    "anymatch": {
+      "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",
+        "picomatch": "^2.0.4"
+      }
     },
-    "buffer-xor": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
-      "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=",
-      "dev": true
+    "argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
     },
-    "builtin-modules": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz",
-      "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=",
+    "balanced-match": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
       "dev": true
     },
-    "builtin-status-codes": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
-      "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=",
+    "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==",
       "dev": true
     },
-    "cacache": {
-      "version": "12.0.4",
-      "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
-      "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==",
+    "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,
       "requires": {
-        "bluebird": "^3.5.5",
-        "chownr": "^1.1.1",
-        "figgy-pudding": "^3.5.1",
-        "glob": "^7.1.4",
-        "graceful-fs": "^4.1.15",
-        "infer-owner": "^1.0.3",
-        "lru-cache": "^5.1.1",
-        "mississippi": "^3.0.0",
-        "mkdirp": "^0.5.1",
-        "move-concurrently": "^1.0.1",
-        "promise-inflight": "^1.0.1",
-        "rimraf": "^2.6.3",
-        "ssri": "^6.0.1",
-        "unique-filename": "^1.1.1",
-        "y18n": "^4.0.0"
-      },
-      "dependencies": {
-        "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==",
-          "dev": true
-        }
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
       }
     },
-    "cache-base": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
-      "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==",
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
       "dev": true,
       "requires": {
-        "collection-visit": "^1.0.0",
-        "component-emitter": "^1.2.1",
-        "get-value": "^2.0.6",
-        "has-value": "^1.0.0",
-        "isobject": "^3.0.1",
-        "set-value": "^2.0.0",
-        "to-object-path": "^0.3.0",
-        "union-value": "^1.0.0",
-        "unset-value": "^1.0.0"
+        "fill-range": "^7.0.1"
       }
     },
     "camelcase": {
       "dev": true
     },
     "chalk": {
-      "version": "2.4.1",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
-      "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==",
+      "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,
       "requires": {
         "ansi-styles": "^3.2.1",
       }
     },
     "chokidar": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz",
-      "integrity": "sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==",
+      "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.3.0"
-      }
-    },
-    "chownr": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-      "dev": true
-    },
-    "chrome-trace-event": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz",
-      "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==",
-      "dev": true,
-      "requires": {
-        "tslib": "^1.9.0"
+        "readdirp": "~3.6.0"
       }
     },
-    "cipher-base": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
-      "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
-      "dev": true,
-      "requires": {
-        "inherits": "^2.0.1",
-        "safe-buffer": "^5.0.1"
-      }
-    },
-    "class-utils": {
-      "version": "0.3.6",
-      "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
-      "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==",
+    "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,
       "requires": {
-        "arr-union": "^3.1.0",
-        "define-property": "^0.2.5",
-        "isobject": "^3.0.0",
-        "static-extend": "^0.1.1"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "0.2.5",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
-          "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^0.1.0"
-          }
-        }
+        "chokidar": "^3.5.2",
+        "lodash.debounce": "^4.0.8",
+        "lodash.throttle": "^4.1.1",
+        "yargs": "^13.3.0"
       }
     },
     "clipboard": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.6.tgz",
-      "integrity": "sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg==",
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.8.tgz",
+      "integrity": "sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==",
       "requires": {
         "good-listener": "^1.2.2",
         "select": "^1.1.2",
       }
     },
     "codemirror": {
-      "version": "5.55.0",
-      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.55.0.tgz",
-      "integrity": "sha512-TumikSANlwiGkdF/Blnu/rqovZ0Y3Jh8yy9TqrPbSM0xxSucq3RgnpVDQ+mD9q6JERJEIT2FMuF/fBGfkhIR/g=="
-    },
-    "collection-visit": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
-      "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=",
-      "dev": true,
-      "requires": {
-        "map-visit": "^1.0.0",
-        "object-visit": "^1.0.0"
-      }
+      "version": "5.62.3",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.3.tgz",
+      "integrity": "sha512-zZAyOfN8TU67ngqrxhOgtkSAGV9jSpN1snbl8elPtnh9Z5A11daR405+dhLzLnuXrwX0WCShWlybxPN3QC/9Pg=="
     },
     "color-convert": {
       "version": "1.9.3",
       "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
       "dev": true
     },
-    "commander": {
-      "version": "2.20.3",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-      "dev": true
-    },
-    "commondir": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
-      "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
-      "dev": true
-    },
-    "component-emitter": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
-      "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==",
-      "dev": true
-    },
     "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
     },
-    "concat-stream": {
-      "version": "1.6.2",
-      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
-      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
-      "dev": true,
-      "requires": {
-        "buffer-from": "^1.0.0",
-        "inherits": "^2.0.3",
-        "readable-stream": "^2.2.2",
-        "typedarray": "^0.0.6"
-      }
-    },
-    "console-browserify": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz",
-      "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==",
-      "dev": true
-    },
-    "constants-browserify": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
-      "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=",
-      "dev": true
-    },
-    "copy-concurrently": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz",
-      "integrity": "sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==",
-      "dev": true,
-      "requires": {
-        "aproba": "^1.1.1",
-        "fs-write-stream-atomic": "^1.0.8",
-        "iferr": "^0.1.5",
-        "mkdirp": "^0.5.1",
-        "rimraf": "^2.5.4",
-        "run-queue": "^1.0.0"
-      }
-    },
-    "copy-descriptor": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
-      "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=",
-      "dev": true
-    },
-    "core-util-is": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
-      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
-      "dev": true
-    },
-    "create-ecdh": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz",
-      "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==",
-      "dev": true,
-      "requires": {
-        "bn.js": "^4.1.0",
-        "elliptic": "^6.0.0"
-      },
-      "dependencies": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
-      }
-    },
-    "create-hash": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
-      "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
-      "dev": true,
-      "requires": {
-        "cipher-base": "^1.0.1",
-        "inherits": "^2.0.1",
-        "md5.js": "^1.3.4",
-        "ripemd160": "^2.0.1",
-        "sha.js": "^2.4.0"
-      }
-    },
-    "create-hmac": {
-      "version": "1.1.7",
-      "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
-      "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
-      "dev": true,
-      "requires": {
-        "cipher-base": "^1.0.3",
-        "create-hash": "^1.1.0",
-        "inherits": "^2.0.1",
-        "ripemd160": "^2.0.0",
-        "safe-buffer": "^5.0.1",
-        "sha.js": "^2.4.8"
-      }
-    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
         "which": "^1.2.9"
       }
     },
-    "crypto-browserify": {
-      "version": "3.12.0",
-      "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
-      "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
-      "dev": true,
-      "requires": {
-        "browserify-cipher": "^1.0.0",
-        "browserify-sign": "^4.0.0",
-        "create-ecdh": "^4.0.0",
-        "create-hash": "^1.1.0",
-        "create-hmac": "^1.1.0",
-        "diffie-hellman": "^5.0.0",
-        "inherits": "^2.0.1",
-        "pbkdf2": "^3.0.3",
-        "public-encrypt": "^4.0.0",
-        "randombytes": "^2.0.0",
-        "randomfill": "^1.0.3"
-      }
-    },
-    "cyclist": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
-      "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=",
-      "dev": true
-    },
-    "debug": {
-      "version": "2.6.9",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
-      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-      "dev": true,
-      "requires": {
-        "ms": "2.0.0"
-      }
-    },
     "decamelize": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
       "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
       "dev": true
     },
-    "decode-uri-component": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
-      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
-      "dev": true
-    },
     "define-properties": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
         "object-keys": "^1.0.12"
       }
     },
-    "define-property": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
-      "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==",
-      "dev": true,
-      "requires": {
-        "is-descriptor": "^1.0.2",
-        "isobject": "^3.0.1"
-      },
-      "dependencies": {
-        "is-accessor-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
-          "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-data-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
-          "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-descriptor": {
-          "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
-          "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
-          "dev": true,
-          "requires": {
-            "is-accessor-descriptor": "^1.0.0",
-            "is-data-descriptor": "^1.0.0",
-            "kind-of": "^6.0.2"
-          }
-        }
-      }
-    },
     "delegate": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
       "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
     },
-    "des.js": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
-      "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==",
-      "dev": true,
-      "requires": {
-        "inherits": "^2.0.1",
-        "minimalistic-assert": "^1.0.0"
-      }
-    },
-    "detect-file": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
-      "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=",
-      "dev": true
-    },
-    "diffie-hellman": {
-      "version": "5.0.3",
-      "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
-      "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
-      "dev": true,
-      "requires": {
-        "bn.js": "^4.1.0",
-        "miller-rabin": "^4.0.0",
-        "randombytes": "^2.0.0"
-      },
-      "dependencies": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
-      }
-    },
-    "domain-browser": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
-      "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==",
-      "dev": true
-    },
     "dropzone": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.7.1.tgz",
-      "integrity": "sha512-eqcJafupMKRlVUihZtsLkzLn02WTPUMHeImP5ZtWm3ERObC/dv15te0qqn/X2rEKaSqRC0pNTHUBPgrPWcP52w=="
-    },
-    "duplexify": {
-      "version": "3.7.1",
-      "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
-      "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
-      "dev": true,
-      "requires": {
-        "end-of-stream": "^1.0.0",
-        "inherits": "^2.0.1",
-        "readable-stream": "^2.0.0",
-        "stream-shift": "^1.0.0"
-      }
-    },
-    "elliptic": {
-      "version": "6.5.3",
-      "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
-      "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
-      "dev": true,
-      "requires": {
-        "bn.js": "^4.4.0",
-        "brorand": "^1.0.1",
-        "hash.js": "^1.0.0",
-        "hmac-drbg": "^1.0.0",
-        "inherits": "^2.0.1",
-        "minimalistic-assert": "^1.0.0",
-        "minimalistic-crypto-utils": "^1.0.0"
-      },
-      "dependencies": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
-      }
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.2.tgz",
+      "integrity": "sha512-5t2z51DzIsWDbTpwcJIvUlwxBbvcwdCApz0yb9ecKJwG155Xm92KMEZmHW1B0MzoXOKvFwdd0nPu5cpeVcvPHQ=="
     },
     "emoji-regex": {
       "version": "7.0.3",
       "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
       "dev": true
     },
-    "emojis-list": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
-      "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
-      "dev": true
-    },
-    "end-of-stream": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
-      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
-      "dev": true,
-      "requires": {
-        "once": "^1.4.0"
-      }
-    },
-    "enhanced-resolve": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.2.0.tgz",
-      "integrity": "sha512-S7eiFb/erugyd1rLb6mQ3Vuq+EXHv5cpCkNqqIkYkBgN2QdFnyCZzFBleqwGEx4lgNGYij81BWnCrFNK7vxvjQ==",
-      "dev": true,
-      "requires": {
-        "graceful-fs": "^4.1.2",
-        "memory-fs": "^0.5.0",
-        "tapable": "^1.0.0"
-      },
-      "dependencies": {
-        "memory-fs": {
-          "version": "0.5.0",
-          "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
-          "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==",
-          "dev": true,
-          "requires": {
-            "errno": "^0.1.3",
-            "readable-stream": "^2.0.1"
-          }
-        }
-      }
-    },
     "entities": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
-      "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
-    },
-    "errno": {
-      "version": "0.1.7",
-      "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz",
-      "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==",
-      "dev": true,
-      "requires": {
-        "prr": "~1.0.1"
-      }
+      "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.12.0",
-      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.12.0.tgz",
-      "integrity": "sha512-C8Fx/0jFmV5IPoMOFPA9P9G5NtqW+4cOPit3MIuvR2t7Ag2K15EJTpxnHAYTzL+aYQJIESYeXZmDBfOBE1HcpA==",
+      "version": "1.17.6",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz",
+      "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==",
       "dev": true,
       "requires": {
-        "es-to-primitive": "^1.1.1",
+        "es-to-primitive": "^1.2.1",
         "function-bind": "^1.1.1",
-        "has": "^1.0.1",
-        "is-callable": "^1.1.3",
-        "is-regex": "^1.0.4"
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1",
+        "is-callable": "^1.2.0",
+        "is-regex": "^1.1.0",
+        "object-inspect": "^1.7.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.0",
+        "string.prototype.trimend": "^1.0.1",
+        "string.prototype.trimstart": "^1.0.1"
       }
     },
     "es-to-primitive": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz",
-      "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==",
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
       "dev": true,
       "requires": {
         "is-callable": "^1.1.4",
         "is-symbol": "^1.0.2"
       }
     },
-    "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
-    },
-    "eslint-scope": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz",
-      "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==",
-      "dev": true,
-      "requires": {
-        "esrecurse": "^4.1.0",
-        "estraverse": "^4.1.1"
-      }
-    },
-    "esrecurse": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz",
-      "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==",
-      "dev": true,
-      "requires": {
-        "estraverse": "^4.1.0"
-      }
-    },
-    "estraverse": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
-      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
-      "dev": true
-    },
-    "events": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz",
-      "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg==",
-      "dev": true
-    },
-    "evp_bytestokey": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
-      "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
-      "dev": true,
-      "requires": {
-        "md5.js": "^1.3.4",
-        "safe-buffer": "^5.1.1"
-      }
-    },
-    "expand-brackets": {
-      "version": "2.1.4",
-      "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
-      "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=",
-      "dev": true,
-      "requires": {
-        "debug": "^2.3.3",
-        "define-property": "^0.2.5",
-        "extend-shallow": "^2.0.1",
-        "posix-character-classes": "^0.1.0",
-        "regex-not": "^1.0.0",
-        "snapdragon": "^0.8.1",
-        "to-regex": "^3.0.1"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "0.2.5",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
-          "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^0.1.0"
-          }
-        },
-        "extend-shallow": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-          "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-          "dev": true,
-          "requires": {
-            "is-extendable": "^0.1.0"
-          }
-        }
-      }
-    },
-    "expand-tilde": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
-      "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=",
-      "dev": true,
-      "requires": {
-        "homedir-polyfill": "^1.0.1"
-      }
-    },
-    "extend-shallow": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
-      "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=",
-      "dev": true,
-      "requires": {
-        "assign-symbols": "^1.0.0",
-        "is-extendable": "^1.0.1"
-      },
-      "dependencies": {
-        "is-extendable": {
-          "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
-          "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
-          "dev": true,
-          "requires": {
-            "is-plain-object": "^2.0.4"
-          }
-        }
-      }
-    },
-    "extglob": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
-      "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==",
-      "dev": true,
-      "requires": {
-        "array-unique": "^0.3.2",
-        "define-property": "^1.0.0",
-        "expand-brackets": "^2.1.4",
-        "extend-shallow": "^2.0.1",
-        "fragment-cache": "^0.2.1",
-        "regex-not": "^1.0.0",
-        "snapdragon": "^0.8.1",
-        "to-regex": "^3.0.1"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
-          "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^1.0.0"
-          }
-        },
-        "extend-shallow": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-          "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-          "dev": true,
-          "requires": {
-            "is-extendable": "^0.1.0"
-          }
-        },
-        "is-accessor-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
-          "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-data-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
-          "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-descriptor": {
-          "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
-          "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
-          "dev": true,
-          "requires": {
-            "is-accessor-descriptor": "^1.0.0",
-            "is-data-descriptor": "^1.0.0",
-            "kind-of": "^6.0.2"
-          }
-        }
-      }
-    },
-    "fast-deep-equal": {
-      "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
-    },
-    "fast-json-stable-stringify": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
-      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-      "dev": true
-    },
-    "figgy-pudding": {
-      "version": "3.5.2",
-      "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
-      "integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==",
+    "esbuild": {
+      "version": "0.12.22",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.22.tgz",
+      "integrity": "sha512-yWCr9RoFehpqoe/+MwZXJpYOEIt7KOEvNnjIeMZpMSyQt+KCBASM3y7yViiN5dJRphf1wGdUz1+M4rTtWd/ulA==",
       "dev": true
     },
-    "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,
-      "requires": {
-        "to-regex-range": "^5.0.1"
-      }
-    },
-    "find-cache-dir": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz",
-      "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==",
-      "dev": true,
-      "requires": {
-        "commondir": "^1.0.1",
-        "make-dir": "^2.0.0",
-        "pkg-dir": "^3.0.0"
-      }
-    },
-    "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,
-      "requires": {
-        "locate-path": "^3.0.0"
-      }
-    },
-    "findup-sync": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz",
-      "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==",
-      "dev": true,
-      "requires": {
-        "detect-file": "^1.0.0",
-        "is-glob": "^4.0.0",
-        "micromatch": "^3.0.4",
-        "resolve-dir": "^1.0.1"
-      }
-    },
-    "flush-write-stream": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
-      "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==",
-      "dev": true,
-      "requires": {
-        "inherits": "^2.0.3",
-        "readable-stream": "^2.3.6"
-      }
-    },
-    "for-in": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
-      "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=",
+    "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
     },
-    "fragment-cache": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
-      "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=",
-      "dev": true,
-      "requires": {
-        "map-cache": "^0.2.2"
-      }
-    },
-    "from2": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz",
-      "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=",
+    "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,
       "requires": {
-        "inherits": "^2.0.1",
-        "readable-stream": "^2.0.0"
+        "to-regex-range": "^5.0.1"
       }
     },
-    "fs-write-stream-atomic": {
-      "version": "1.0.10",
-      "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
-      "integrity": "sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=",
+    "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,
       "requires": {
-        "graceful-fs": "^4.1.2",
-        "iferr": "^0.1.5",
-        "imurmurhash": "^0.1.4",
-        "readable-stream": "1 || 2"
+        "locate-path": "^3.0.0"
       }
     },
-    "fs.realpath": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
-      "dev": true
-    },
     "fsevents": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz",
-      "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==",
+      "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-value": {
-      "version": "2.0.6",
-      "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
-      "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=",
-      "dev": true
-    },
-    "glob": {
-      "version": "7.1.6",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
-      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
-      "dev": true,
-      "requires": {
-        "fs.realpath": "^1.0.0",
-        "inflight": "^1.0.4",
-        "inherits": "2",
-        "minimatch": "^3.0.4",
-        "once": "^1.3.0",
-        "path-is-absolute": "^1.0.0"
-      }
-    },
     "glob-parent": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz",
-      "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==",
+      "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"
       }
     },
-    "global-modules": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
-      "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
-      "dev": true,
-      "requires": {
-        "global-prefix": "^3.0.0"
-      },
-      "dependencies": {
-        "global-prefix": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz",
-          "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
-          "dev": true,
-          "requires": {
-            "ini": "^1.3.5",
-            "kind-of": "^6.0.2",
-            "which": "^1.3.1"
-          }
-        }
-      }
-    },
-    "global-prefix": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
-      "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=",
-      "dev": true,
-      "requires": {
-        "expand-tilde": "^2.0.2",
-        "homedir-polyfill": "^1.0.1",
-        "ini": "^1.3.4",
-        "is-windows": "^1.0.1",
-        "which": "^1.2.14"
-      }
-    },
     "good-listener": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
       }
     },
     "graceful-fs": {
-      "version": "4.1.11",
-      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
-      "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=",
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
       "dev": true
     },
     "has": {
       "dev": true
     },
     "has-symbols": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz",
-      "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=",
-      "dev": true
-    },
-    "has-value": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
-      "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=",
-      "dev": true,
-      "requires": {
-        "get-value": "^2.0.6",
-        "has-values": "^1.0.0",
-        "isobject": "^3.0.0"
-      }
-    },
-    "has-values": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz",
-      "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=",
-      "dev": true,
-      "requires": {
-        "is-number": "^3.0.0",
-        "kind-of": "^4.0.0"
-      },
-      "dependencies": {
-        "is-number": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
-          "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
-          "dev": true,
-          "requires": {
-            "kind-of": "^3.0.2"
-          },
-          "dependencies": {
-            "kind-of": {
-              "version": "3.2.2",
-              "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-              "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-              "dev": true,
-              "requires": {
-                "is-buffer": "^1.1.5"
-              }
-            }
-          }
-        },
-        "kind-of": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
-          "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=",
-          "dev": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        }
-      }
-    },
-    "hash-base": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
-      "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
-      "dev": true,
-      "requires": {
-        "inherits": "^2.0.4",
-        "readable-stream": "^3.6.0",
-        "safe-buffer": "^5.2.0"
-      },
-      "dependencies": {
-        "readable-stream": {
-          "version": "3.6.0",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
-          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
-          "dev": true,
-          "requires": {
-            "inherits": "^2.0.3",
-            "string_decoder": "^1.1.1",
-            "util-deprecate": "^1.0.1"
-          }
-        },
-        "safe-buffer": {
-          "version": "5.2.1",
-          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
-          "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
-          "dev": true
-        }
-      }
-    },
-    "hash.js": {
-      "version": "1.1.7",
-      "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
-      "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
-      "dev": true,
-      "requires": {
-        "inherits": "^2.0.3",
-        "minimalistic-assert": "^1.0.1"
-      }
-    },
-    "hmac-drbg": {
       "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
-      "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
-      "dev": true,
-      "requires": {
-        "hash.js": "^1.0.3",
-        "minimalistic-assert": "^1.0.0",
-        "minimalistic-crypto-utils": "^1.0.1"
-      }
-    },
-    "homedir-polyfill": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
-      "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
-      "dev": true,
-      "requires": {
-        "parse-passwd": "^1.0.0"
-      }
-    },
-    "hosted-git-info": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz",
-      "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==",
-      "dev": true
-    },
-    "https-browserify": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
-      "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
-      "dev": true
-    },
-    "ieee754": {
-      "version": "1.1.13",
-      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
-      "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
-      "dev": true
-    },
-    "iferr": {
-      "version": "0.1.5",
-      "resolved": "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz",
-      "integrity": "sha1-xg7taebY/bazEEofy8ocGS3FtQE=",
-      "dev": true
-    },
-    "import-local": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz",
-      "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==",
-      "dev": true,
-      "requires": {
-        "pkg-dir": "^3.0.0",
-        "resolve-cwd": "^2.0.0"
-      }
-    },
-    "imurmurhash": {
-      "version": "0.1.4",
-      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
-      "dev": true
-    },
-    "infer-owner": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
-      "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
-      "dev": true
-    },
-    "inflight": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
-      "dev": true,
-      "requires": {
-        "once": "^1.3.0",
-        "wrappy": "1"
-      }
-    },
-    "inherits": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true
-    },
-    "ini": {
-      "version": "1.3.5",
-      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
-      "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
+      "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
       "dev": true
     },
-    "interpret": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
-      "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
+    "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
     },
-    "is-accessor-descriptor": {
-      "version": "0.1.6",
-      "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
-      "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=",
-      "dev": true,
-      "requires": {
-        "kind-of": "^3.0.2"
-      },
-      "dependencies": {
-        "kind-of": {
-          "version": "3.2.2",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-          "dev": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        }
-      }
-    },
     "is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
         "binary-extensions": "^2.0.0"
       }
     },
-    "is-buffer": {
-      "version": "1.1.6",
-      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
-      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
-      "dev": true
-    },
-    "is-builtin-module": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz",
-      "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=",
-      "dev": true,
-      "requires": {
-        "builtin-modules": "^1.0.0"
-      }
-    },
     "is-callable": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
-      "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz",
+      "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==",
       "dev": true
     },
-    "is-data-descriptor": {
-      "version": "0.1.4",
-      "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
-      "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=",
-      "dev": true,
-      "requires": {
-        "kind-of": "^3.0.2"
-      },
-      "dependencies": {
-        "kind-of": {
-          "version": "3.2.2",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-          "dev": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        }
-      }
-    },
     "is-date-object": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
-      "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=",
-      "dev": true
-    },
-    "is-descriptor": {
-      "version": "0.1.6",
-      "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
-      "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
-      "dev": true,
-      "requires": {
-        "is-accessor-descriptor": "^0.1.6",
-        "is-data-descriptor": "^0.1.4",
-        "kind-of": "^5.0.0"
-      },
-      "dependencies": {
-        "kind-of": {
-          "version": "5.1.0",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
-          "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
-          "dev": true
-        }
-      }
-    },
-    "is-extendable": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
-      "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=",
+      "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
     },
     "is-extglob": {
       "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
       "dev": true
     },
-    "is-plain-object": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
-      "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
-      "dev": true,
-      "requires": {
-        "isobject": "^3.0.1"
-      }
-    },
     "is-regex": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz",
-      "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz",
+      "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==",
       "dev": true,
       "requires": {
-        "has": "^1.0.1"
+        "has-symbols": "^1.0.1"
       }
     },
     "is-symbol": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz",
-      "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
+      "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
       "dev": true,
       "requires": {
-        "has-symbols": "^1.0.0"
+        "has-symbols": "^1.0.1"
       }
     },
-    "is-windows": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
-      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
-      "dev": true
-    },
-    "is-wsl": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
-      "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=",
-      "dev": true
-    },
-    "isarray": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
-      "dev": true
-    },
     "isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
       "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
       "dev": true
     },
-    "isobject": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
-      "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
-      "dev": true
-    },
     "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
     },
-    "json-schema-traverse": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
-      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
-      "dev": true
-    },
-    "json5": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
-      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
-      "dev": true,
-      "requires": {
-        "minimist": "^1.2.0"
-      }
-    },
-    "jsonify": {
-      "version": "0.0.0",
-      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
-      "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
-      "dev": true
-    },
-    "kind-of": {
-      "version": "6.0.3",
-      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
-      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
-      "dev": true
-    },
     "linkify-it": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz",
       }
     },
     "livereload": {
-      "version": "0.9.1",
-      "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.1.tgz",
-      "integrity": "sha512-9g7sua11kkyZNo2hLRCG3LuZZwqexoyEyecSlV8cAsfAVVCZqLzVir6XDqmH0r+Vzgnd5LrdHDMyjtFnJQLAYw==",
+      "version": "0.9.3",
+      "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz",
+      "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==",
       "dev": true,
       "requires": {
-        "chokidar": "^3.3.0",
-        "livereload-js": "^3.1.0",
+        "chokidar": "^3.5.0",
+        "livereload-js": "^3.3.1",
         "opts": ">= 1.2.0",
-        "ws": "^6.2.1"
+        "ws": "^7.4.3"
       }
     },
     "livereload-js": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.2.2.tgz",
-      "integrity": "sha512-xhScbNeC687ZINjEf/bD+BMiPx4s4q0mehcLb3zCc8+mykOtmaBR4vqzyIV9rIGdG9JjHaT0LiFdscvivCjX1Q==",
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.3.2.tgz",
+      "integrity": "sha512-w677WnINxFkuixAoUEXOStewzLYGI76XVag+0JWMMEyjJQKs0ibWZMxkTlB96Lm3EjZ7IeOxVziBEbtxVQqQZA==",
       "dev": true
     },
-    "loader-runner": {
-      "version": "2.4.0",
-      "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz",
-      "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==",
-      "dev": true
-    },
-    "loader-utils": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
-      "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+    "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,
       "requires": {
-        "big.js": "^5.2.2",
-        "emojis-list": "^3.0.0",
-        "json5": "^1.0.1"
+        "graceful-fs": "^4.1.2",
+        "parse-json": "^4.0.0",
+        "pify": "^3.0.0",
+        "strip-bom": "^3.0.0"
       }
     },
     "locate-path": {
         "path-exists": "^3.0.0"
       }
     },
-    "lru-cache": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
-      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
-      "dev": true,
-      "requires": {
-        "yallist": "^3.0.2"
-      }
-    },
-    "make-dir": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
-      "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
-      "dev": true,
-      "requires": {
-        "pify": "^4.0.1",
-        "semver": "^5.6.0"
-      },
-      "dependencies": {
-        "pify": {
-          "version": "4.0.1",
-          "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
-          "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
-          "dev": true
-        }
-      }
-    },
-    "map-cache": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
-      "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=",
+    "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
     },
-    "map-visit": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
-      "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=",
-      "dev": true,
-      "requires": {
-        "object-visit": "^1.0.0"
-      }
+    "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
     },
     "markdown-it": {
-      "version": "11.0.0",
-      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.0.tgz",
-      "integrity": "sha512-+CvOnmbSubmQFSA9dKz1BRiaSMV7rhexl3sngKqFyXSagoA3fBdJQ8oZWtRy2knXdpDXaBw44euz37DeJQ9asg==",
+      "version": "12.2.0",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.2.0.tgz",
+      "integrity": "sha512-Wjws+uCrVQRqOoJvze4HCqkKl1AsSh95iFAeQDwnyfxM09divCBSXlDR1uTvyUP3Grzpn4Ru8GeCxYPM8vkCQg==",
       "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"
       "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
       "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
     },
-    "md5.js": {
-      "version": "1.3.5",
-      "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
-      "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
-      "dev": true,
-      "requires": {
-        "hash-base": "^3.0.0",
-        "inherits": "^2.0.1",
-        "safe-buffer": "^5.1.2"
-      }
-    },
     "mdurl": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
       "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
     },
-    "memory-fs": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
-      "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=",
-      "dev": true,
-      "requires": {
-        "errno": "^0.1.3",
-        "readable-stream": "^2.0.1"
-      }
-    },
     "memorystream": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
       "integrity": "sha1-htcJCzDORV1j+64S3aUaR93K+bI=",
       "dev": true
     },
-    "micromatch": {
-      "version": "3.1.10",
-      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
-      "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
-      "dev": true,
-      "requires": {
-        "arr-diff": "^4.0.0",
-        "array-unique": "^0.3.2",
-        "braces": "^2.3.1",
-        "define-property": "^2.0.2",
-        "extend-shallow": "^3.0.2",
-        "extglob": "^2.0.4",
-        "fragment-cache": "^0.2.1",
-        "kind-of": "^6.0.2",
-        "nanomatch": "^1.2.9",
-        "object.pick": "^1.3.0",
-        "regex-not": "^1.0.0",
-        "snapdragon": "^0.8.1",
-        "to-regex": "^3.0.2"
-      },
-      "dependencies": {
-        "braces": {
-          "version": "2.3.2",
-          "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
-          "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
-          "dev": true,
-          "requires": {
-            "arr-flatten": "^1.1.0",
-            "array-unique": "^0.3.2",
-            "extend-shallow": "^2.0.1",
-            "fill-range": "^4.0.0",
-            "isobject": "^3.0.1",
-            "repeat-element": "^1.1.2",
-            "snapdragon": "^0.8.1",
-            "snapdragon-node": "^2.0.1",
-            "split-string": "^3.0.2",
-            "to-regex": "^3.0.1"
-          },
-          "dependencies": {
-            "extend-shallow": {
-              "version": "2.0.1",
-              "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-              "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-              "dev": true,
-              "requires": {
-                "is-extendable": "^0.1.0"
-              }
-            }
-          }
-        },
-        "fill-range": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
-          "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=",
-          "dev": true,
-          "requires": {
-            "extend-shallow": "^2.0.1",
-            "is-number": "^3.0.0",
-            "repeat-string": "^1.6.1",
-            "to-regex-range": "^2.1.0"
-          },
-          "dependencies": {
-            "extend-shallow": {
-              "version": "2.0.1",
-              "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-              "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-              "dev": true,
-              "requires": {
-                "is-extendable": "^0.1.0"
-              }
-            }
-          }
-        },
-        "is-number": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
-          "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
-          "dev": true,
-          "requires": {
-            "kind-of": "^3.0.2"
-          },
-          "dependencies": {
-            "kind-of": {
-              "version": "3.2.2",
-              "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-              "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-              "dev": true,
-              "requires": {
-                "is-buffer": "^1.1.5"
-              }
-            }
-          }
-        },
-        "to-regex-range": {
-          "version": "2.1.1",
-          "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
-          "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=",
-          "dev": true,
-          "requires": {
-            "is-number": "^3.0.0",
-            "repeat-string": "^1.6.1"
-          }
-        }
-      }
-    },
-    "miller-rabin": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
-      "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
-      "dev": true,
-      "requires": {
-        "bn.js": "^4.0.0",
-        "brorand": "^1.0.1"
-      },
-      "dependencies": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
-      }
-    },
-    "minimalistic-assert": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
-      "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
-      "dev": true
-    },
-    "minimalistic-crypto-utils": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
-      "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
-      "dev": true
-    },
     "minimatch": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
       "dev": true,
       "requires": {
         "brace-expansion": "^1.1.7"
-      }
-    },
-    "minimist": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
-      "dev": true
-    },
-    "mississippi": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
-      "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==",
-      "dev": true,
-      "requires": {
-        "concat-stream": "^1.5.0",
-        "duplexify": "^3.4.2",
-        "end-of-stream": "^1.1.0",
-        "flush-write-stream": "^1.0.0",
-        "from2": "^2.1.0",
-        "parallel-transform": "^1.1.0",
-        "pump": "^3.0.0",
-        "pumpify": "^1.3.3",
-        "stream-each": "^1.1.0",
-        "through2": "^2.0.0"
-      }
-    },
-    "mixin-deep": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
-      "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
-      "dev": true,
-      "requires": {
-        "for-in": "^1.0.2",
-        "is-extendable": "^1.0.1"
-      },
-      "dependencies": {
-        "is-extendable": {
-          "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
-          "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
-          "dev": true,
-          "requires": {
-            "is-plain-object": "^2.0.4"
-          }
-        }
-      }
-    },
-    "mkdirp": {
-      "version": "0.5.5",
-      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
-      "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
-      "dev": true,
-      "requires": {
-        "minimist": "^1.2.5"
-      }
-    },
-    "move-concurrently": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
-      "integrity": "sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=",
-      "dev": true,
-      "requires": {
-        "aproba": "^1.1.1",
-        "copy-concurrently": "^1.0.0",
-        "fs-write-stream-atomic": "^1.0.8",
-        "mkdirp": "^0.5.1",
-        "rimraf": "^2.5.4",
-        "run-queue": "^1.0.3"
-      }
-    },
-    "ms": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
-      "dev": true
-    },
-    "nanomatch": {
-      "version": "1.2.13",
-      "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
-      "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==",
-      "dev": true,
-      "requires": {
-        "arr-diff": "^4.0.0",
-        "array-unique": "^0.3.2",
-        "define-property": "^2.0.2",
-        "extend-shallow": "^3.0.2",
-        "fragment-cache": "^0.2.1",
-        "is-windows": "^1.0.2",
-        "kind-of": "^6.0.2",
-        "object.pick": "^1.3.0",
-        "regex-not": "^1.0.0",
-        "snapdragon": "^0.8.1",
-        "to-regex": "^3.0.1"
-      }
-    },
-    "neo-async": {
-      "version": "2.6.1",
-      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz",
-      "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==",
-      "dev": true
+      }
     },
     "nice-try": {
       "version": "1.0.5",
       "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
       "dev": true
     },
-    "node-libs-browser": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
-      "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==",
-      "dev": true,
-      "requires": {
-        "assert": "^1.1.1",
-        "browserify-zlib": "^0.2.0",
-        "buffer": "^4.3.0",
-        "console-browserify": "^1.1.0",
-        "constants-browserify": "^1.0.0",
-        "crypto-browserify": "^3.11.0",
-        "domain-browser": "^1.1.1",
-        "events": "^3.0.0",
-        "https-browserify": "^1.0.0",
-        "os-browserify": "^0.3.0",
-        "path-browserify": "0.0.1",
-        "process": "^0.11.10",
-        "punycode": "^1.2.4",
-        "querystring-es3": "^0.2.0",
-        "readable-stream": "^2.3.3",
-        "stream-browserify": "^2.0.1",
-        "stream-http": "^2.7.2",
-        "string_decoder": "^1.0.0",
-        "timers-browserify": "^2.0.4",
-        "tty-browserify": "0.0.0",
-        "url": "^0.11.0",
-        "util": "^0.11.0",
-        "vm-browserify": "^1.0.1"
-      },
-      "dependencies": {
-        "punycode": {
-          "version": "1.4.1",
-          "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
-          "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
-          "dev": true
-        }
-      }
-    },
     "normalize-package-data": {
-      "version": "2.4.0",
-      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
-      "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==",
+      "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,
       "requires": {
         "hosted-git-info": "^2.1.4",
-        "is-builtin-module": "^1.0.0",
+        "resolve": "^1.10.0",
         "semver": "2 || 3 || 4 || 5",
         "validate-npm-package-license": "^3.0.1"
       }
         "read-pkg": "^3.0.0",
         "shell-quote": "^1.6.1",
         "string.prototype.padend": "^3.0.0"
-      },
-      "dependencies": {
-        "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,
-          "requires": {
-            "nice-try": "^1.0.4",
-            "path-key": "^2.0.1",
-            "semver": "^5.5.0",
-            "shebang-command": "^1.2.0",
-            "which": "^1.2.9"
-          }
-        },
-        "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,
-          "requires": {
-            "graceful-fs": "^4.1.2",
-            "parse-json": "^4.0.0",
-            "pify": "^3.0.0",
-            "strip-bom": "^3.0.0"
-          }
-        },
-        "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,
-          "requires": {
-            "error-ex": "^1.3.1",
-            "json-parse-better-errors": "^1.0.1"
-          }
-        },
-        "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,
-          "requires": {
-            "pify": "^3.0.0"
-          }
-        },
-        "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,
-          "requires": {
-            "load-json-file": "^4.0.0",
-            "normalize-package-data": "^2.3.2",
-            "path-type": "^3.0.0"
-          }
-        },
-        "strip-bom": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
-          "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
-          "dev": true
-        }
       }
     },
-    "object-assign": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+    "object-inspect": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz",
+      "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==",
       "dev": true
     },
-    "object-copy": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
-      "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=",
-      "dev": true,
-      "requires": {
-        "copy-descriptor": "^0.1.0",
-        "define-property": "^0.2.5",
-        "kind-of": "^3.0.3"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "0.2.5",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
-          "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^0.1.0"
-          }
-        },
-        "kind-of": {
-          "version": "3.2.2",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-          "dev": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        }
-      }
-    },
     "object-keys": {
-      "version": "1.0.12",
-      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz",
-      "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
       "dev": true
     },
-    "object-visit": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
-      "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=",
-      "dev": true,
-      "requires": {
-        "isobject": "^3.0.0"
-      }
-    },
-    "object.pick": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
-      "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=",
-      "dev": true,
-      "requires": {
-        "isobject": "^3.0.1"
-      }
-    },
-    "once": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+    "object.assign": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
+      "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
       "dev": true,
       "requires": {
-        "wrappy": "1"
+        "define-properties": "^1.1.2",
+        "function-bind": "^1.1.1",
+        "has-symbols": "^1.0.0",
+        "object-keys": "^1.0.11"
       }
     },
     "opts": {
-      "version": "1.2.7",
-      "resolved": "https://registry.npmjs.org/opts/-/opts-1.2.7.tgz",
-      "integrity": "sha512-hwZhzGGG/GQ7igxAVFOEun2N4fWul31qE9nfBdCnZGQCB5+L7tN9xZ+94B4aUpLOJx/of3zZs5XsuubayQYQjA==",
-      "dev": true
-    },
-    "os-browserify": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
-      "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=",
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz",
+      "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
       "dev": true
     },
     "p-limit": {
       "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
       "dev": true
     },
-    "pako": {
-      "version": "1.0.11",
-      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
-      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
-      "dev": true
-    },
-    "parallel-transform": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",
-      "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==",
-      "dev": true,
-      "requires": {
-        "cyclist": "^1.0.1",
-        "inherits": "^2.0.3",
-        "readable-stream": "^2.1.5"
-      }
-    },
-    "parse-asn1": {
-      "version": "5.1.5",
-      "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.5.tgz",
-      "integrity": "sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==",
+    "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,
       "requires": {
-        "asn1.js": "^4.0.0",
-        "browserify-aes": "^1.0.0",
-        "create-hash": "^1.1.0",
-        "evp_bytestokey": "^1.0.0",
-        "pbkdf2": "^3.0.3",
-        "safe-buffer": "^5.1.1"
+        "error-ex": "^1.3.1",
+        "json-parse-better-errors": "^1.0.1"
       }
     },
-    "parse-passwd": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
-      "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=",
-      "dev": true
-    },
-    "pascalcase": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
-      "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=",
-      "dev": true
-    },
-    "path-browserify": {
-      "version": "0.0.1",
-      "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
-      "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==",
-      "dev": true
-    },
-    "path-dirname": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
-      "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=",
-      "dev": true,
-      "optional": true
-    },
     "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
     },
-    "path-is-absolute": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
-      "dev": true
-    },
     "path-key": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
       "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
       "dev": true
     },
-    "pbkdf2": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz",
-      "integrity": "sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==",
+    "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
+    },
+    "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,
       "requires": {
-        "create-hash": "^1.1.2",
-        "create-hmac": "^1.1.4",
-        "ripemd160": "^2.0.1",
-        "safe-buffer": "^5.0.1",
-        "sha.js": "^2.4.8"
+        "pify": "^3.0.0"
       }
     },
     "picomatch": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz",
-      "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==",
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
+      "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
       "dev": true
     },
     "pidtree": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.0.tgz",
-      "integrity": "sha512-9CT4NFlDcosssyg8KVFltgokyKZIFjoBxw8CTGy+5F38Y1eQWrt8tRayiUOXE+zVKQnYu5BR8JjCtvK3BcnBhg==",
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz",
+      "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==",
       "dev": true
     },
     "pify": {
       "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
       "dev": true
     },
-    "pkg-dir": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
-      "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
-      "dev": true,
-      "requires": {
-        "find-up": "^3.0.0"
-      }
-    },
-    "posix-character-classes": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
-      "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=",
-      "dev": true
-    },
-    "process": {
-      "version": "0.11.10",
-      "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
-      "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=",
-      "dev": true
-    },
-    "process-nextick-args": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
-      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "dev": true
-    },
-    "promise-inflight": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
-      "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=",
-      "dev": true
-    },
-    "prr": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
-      "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=",
-      "dev": true
-    },
-    "public-encrypt": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
-      "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
-      "dev": true,
-      "requires": {
-        "bn.js": "^4.1.0",
-        "browserify-rsa": "^4.0.0",
-        "create-hash": "^1.1.0",
-        "parse-asn1": "^5.0.0",
-        "randombytes": "^2.0.1",
-        "safe-buffer": "^5.1.2"
-      },
-      "dependencies": {
-        "bn.js": {
-          "version": "4.11.9",
-          "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz",
-          "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==",
-          "dev": true
-        }
-      }
-    },
-    "pump": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
-      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
-      "dev": true,
-      "requires": {
-        "end-of-stream": "^1.1.0",
-        "once": "^1.3.1"
-      }
-    },
-    "pumpify": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz",
-      "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==",
-      "dev": true,
-      "requires": {
-        "duplexify": "^3.6.0",
-        "inherits": "^2.0.3",
-        "pump": "^2.0.0"
-      },
-      "dependencies": {
-        "pump": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",
-          "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==",
-          "dev": true,
-          "requires": {
-            "end-of-stream": "^1.1.0",
-            "once": "^1.3.1"
-          }
-        }
-      }
-    },
     "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
     },
-    "querystring": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
-      "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
-      "dev": true
-    },
-    "querystring-es3": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
-      "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
-      "dev": true
-    },
-    "randombytes": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
-      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
-      "dev": true,
-      "requires": {
-        "safe-buffer": "^5.1.0"
-      }
-    },
-    "randomfill": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
-      "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
-      "dev": true,
-      "requires": {
-        "randombytes": "^2.0.5",
-        "safe-buffer": "^5.1.0"
-      }
-    },
-    "readable-stream": {
-      "version": "2.3.7",
-      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
-      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+    "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,
       "requires": {
-        "core-util-is": "~1.0.0",
-        "inherits": "~2.0.3",
-        "isarray": "~1.0.0",
-        "process-nextick-args": "~2.0.0",
-        "safe-buffer": "~5.1.1",
-        "string_decoder": "~1.1.1",
-        "util-deprecate": "~1.0.1"
+        "load-json-file": "^4.0.0",
+        "normalize-package-data": "^2.3.2",
+        "path-type": "^3.0.0"
       }
     },
     "readdirp": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz",
-      "integrity": "sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==",
-      "dev": true,
-      "requires": {
-        "picomatch": "^2.0.7"
-      }
-    },
-    "regex-not": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
-      "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==",
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
       "dev": true,
       "requires": {
-        "extend-shallow": "^3.0.2",
-        "safe-regex": "^1.1.0"
+        "picomatch": "^2.2.1"
       }
     },
-    "remove-trailing-separator": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
-      "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=",
-      "dev": true,
-      "optional": true
-    },
-    "repeat-element": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz",
-      "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==",
-      "dev": true
-    },
-    "repeat-string": {
-      "version": "1.6.1",
-      "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
-      "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=",
-      "dev": true
-    },
     "require-directory": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
       "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
       "dev": true
     },
-    "resolve-cwd": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz",
-      "integrity": "sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=",
-      "dev": true,
-      "requires": {
-        "resolve-from": "^3.0.0"
-      }
-    },
-    "resolve-dir": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
-      "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=",
-      "dev": true,
-      "requires": {
-        "expand-tilde": "^2.0.0",
-        "global-modules": "^1.0.0"
-      },
-      "dependencies": {
-        "global-modules": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
-          "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
-          "dev": true,
-          "requires": {
-            "global-prefix": "^1.0.1",
-            "is-windows": "^1.0.1",
-            "resolve-dir": "^1.0.0"
-          }
-        }
-      }
-    },
-    "resolve-from": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
-      "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=",
-      "dev": true
-    },
-    "resolve-url": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
-      "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=",
-      "dev": true
-    },
-    "ret": {
-      "version": "0.1.15",
-      "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
-      "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
-      "dev": true
-    },
-    "rimraf": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
-      "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
-      "dev": true,
-      "requires": {
-        "glob": "^7.1.3"
-      }
-    },
-    "ripemd160": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
-      "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
-      "dev": true,
-      "requires": {
-        "hash-base": "^3.0.0",
-        "inherits": "^2.0.1"
-      }
-    },
-    "run-queue": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
-      "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=",
-      "dev": true,
-      "requires": {
-        "aproba": "^1.1.1"
-      }
-    },
-    "safe-buffer": {
-      "version": "5.1.2",
-      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
-      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "dev": true
-    },
-    "safe-regex": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
-      "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
+    "resolve": {
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
+      "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
       "dev": true,
       "requires": {
-        "ret": "~0.1.10"
+        "path-parse": "^1.0.6"
       }
     },
     "sass": {
-      "version": "1.26.9",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.9.tgz",
-      "integrity": "sha512-t8AkRVi+xvba4yZiLWkJdgJHBFCB3Dh4johniQkPy9ywkgFHNasXFEFP+RG/F6LhQ+aoE4aX+IorIWQjS0esVw==",
-      "dev": true,
-      "requires": {
-        "chokidar": ">=2.0.0 <4.0.0"
-      }
-    },
-    "schema-utils": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz",
-      "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==",
+      "version": "1.38.0",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.38.0.tgz",
+      "integrity": "sha512-WBccZeMigAGKoI+NgD7Adh0ab1HUq+6BmyBUEaGxtErbUtWUevEbdgo5EZiJQofLUGcKtlNaO2IdN73AHEua5g==",
       "dev": true,
       "requires": {
-        "ajv": "^6.1.0",
-        "ajv-errors": "^1.0.0",
-        "ajv-keywords": "^3.1.0"
+        "chokidar": ">=3.0.0 <4.0.0"
       }
     },
     "select": {
       "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0="
     },
     "semver": {
-      "version": "5.6.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
-      "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==",
-      "dev": true
-    },
-    "serialize-javascript": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
-      "integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
-      "dev": true,
-      "requires": {
-        "randombytes": "^2.1.0"
-      }
-    },
-    "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
-    },
-    "set-value": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
-      "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
-      "dev": true,
-      "requires": {
-        "extend-shallow": "^2.0.1",
-        "is-extendable": "^0.1.1",
-        "is-plain-object": "^2.0.3",
-        "split-string": "^3.0.1"
-      },
-      "dependencies": {
-        "extend-shallow": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-          "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-          "dev": true,
-          "requires": {
-            "is-extendable": "^0.1.0"
-          }
-        }
-      }
-    },
-    "setimmediate": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
-      "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=",
-      "dev": true
-    },
-    "sha.js": {
-      "version": "2.4.11",
-      "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
-      "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
-      "dev": true,
-      "requires": {
-        "inherits": "^2.0.1",
-        "safe-buffer": "^5.0.1"
-      }
-    },
-    "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,
-      "requires": {
-        "shebang-regex": "^1.0.0"
-      }
-    },
-    "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
-    },
-    "shell-quote": {
-      "version": "1.6.1",
-      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz",
-      "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=",
-      "dev": true,
-      "requires": {
-        "array-filter": "~0.0.0",
-        "array-map": "~0.0.0",
-        "array-reduce": "~0.0.0",
-        "jsonify": "~0.0.0"
-      }
-    },
-    "snapdragon": {
-      "version": "0.8.2",
-      "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
-      "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==",
-      "dev": true,
-      "requires": {
-        "base": "^0.11.1",
-        "debug": "^2.2.0",
-        "define-property": "^0.2.5",
-        "extend-shallow": "^2.0.1",
-        "map-cache": "^0.2.2",
-        "source-map": "^0.5.6",
-        "source-map-resolve": "^0.5.0",
-        "use": "^3.1.0"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "0.2.5",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
-          "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^0.1.0"
-          }
-        },
-        "extend-shallow": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-          "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-          "dev": true,
-          "requires": {
-            "is-extendable": "^0.1.0"
-          }
-        }
-      }
-    },
-    "snapdragon-node": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz",
-      "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==",
-      "dev": true,
-      "requires": {
-        "define-property": "^1.0.0",
-        "isobject": "^3.0.0",
-        "snapdragon-util": "^3.0.1"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
-          "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^1.0.0"
-          }
-        },
-        "is-accessor-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
-          "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-data-descriptor": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
-          "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
-          "dev": true,
-          "requires": {
-            "kind-of": "^6.0.0"
-          }
-        },
-        "is-descriptor": {
-          "version": "1.0.2",
-          "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
-          "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
-          "dev": true,
-          "requires": {
-            "is-accessor-descriptor": "^1.0.0",
-            "is-data-descriptor": "^1.0.0",
-            "kind-of": "^6.0.2"
-          }
-        }
-      }
-    },
-    "snapdragon-util": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz",
-      "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==",
-      "dev": true,
-      "requires": {
-        "kind-of": "^3.2.0"
-      },
-      "dependencies": {
-        "kind-of": {
-          "version": "3.2.2",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-          "dev": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        }
-      }
-    },
-    "sortablejs": {
-      "version": "1.10.2",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
-      "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
-    },
-    "source-list-map": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
-      "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==",
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
       "dev": true
     },
-    "source-map": {
-      "version": "0.5.7",
-      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
-      "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
+    "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
     },
-    "source-map-resolve": {
-      "version": "0.5.3",
-      "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
-      "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+    "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,
       "requires": {
-        "atob": "^2.1.2",
-        "decode-uri-component": "^0.2.0",
-        "resolve-url": "^0.2.1",
-        "source-map-url": "^0.4.0",
-        "urix": "^0.1.0"
+        "shebang-regex": "^1.0.0"
       }
     },
-    "source-map-support": {
-      "version": "0.5.19",
-      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
-      "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
-      "dev": true,
-      "requires": {
-        "buffer-from": "^1.0.0",
-        "source-map": "^0.6.0"
-      },
-      "dependencies": {
-        "source-map": {
-          "version": "0.6.1",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-          "dev": true
-        }
-      }
+    "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
     },
-    "source-map-url": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz",
-      "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=",
+    "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==",
       "dev": true
     },
+    "sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
+    },
     "spdx-correct": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.2.tgz",
-      "integrity": "sha512-q9hedtzyXHr5S0A1vEPoK/7l8NpfkFYTq6iCY+Pno2ZbdZR6WexZFtqeVGkGxW3TEJMN914Z55EnAGMmenlIQQ==",
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
       "dev": true,
       "requires": {
         "spdx-expression-parse": "^3.0.0",
       }
     },
     "spdx-exceptions": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz",
-      "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==",
+      "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
     },
     "spdx-expression-parse": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz",
-      "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==",
+      "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,
       "requires": {
         "spdx-exceptions": "^2.1.0",
       }
     },
     "spdx-license-ids": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.2.tgz",
-      "integrity": "sha512-qky9CVt0lVIECkEsYbNILVnPvycuEBkXoMFLRWsREkomQLevYhtRKC+R91a5TOAQ3bCMjikRwhyaRqj1VYatYg==",
-      "dev": true
-    },
-    "split-string": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
-      "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
-      "dev": true,
-      "requires": {
-        "extend-shallow": "^3.0.0"
-      }
-    },
-    "sprintf-js": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
-    },
-    "ssri": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz",
-      "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==",
-      "dev": true,
-      "requires": {
-        "figgy-pudding": "^3.5.1"
-      }
-    },
-    "static-extend": {
-      "version": "0.1.2",
-      "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
-      "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=",
-      "dev": true,
-      "requires": {
-        "define-property": "^0.2.5",
-        "object-copy": "^0.1.0"
-      },
-      "dependencies": {
-        "define-property": {
-          "version": "0.2.5",
-          "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
-          "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
-          "dev": true,
-          "requires": {
-            "is-descriptor": "^0.1.0"
-          }
-        }
-      }
-    },
-    "stream-browserify": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz",
-      "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==",
-      "dev": true,
-      "requires": {
-        "inherits": "~2.0.1",
-        "readable-stream": "^2.0.2"
-      }
-    },
-    "stream-each": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/stream-each/-/stream-each-1.2.3.tgz",
-      "integrity": "sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==",
-      "dev": true,
-      "requires": {
-        "end-of-stream": "^1.1.0",
-        "stream-shift": "^1.0.0"
-      }
-    },
-    "stream-http": {
-      "version": "2.8.3",
-      "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz",
-      "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==",
-      "dev": true,
-      "requires": {
-        "builtin-status-codes": "^3.0.0",
-        "inherits": "^2.0.1",
-        "readable-stream": "^2.3.6",
-        "to-arraybuffer": "^1.0.0",
-        "xtend": "^4.0.0"
-      }
-    },
-    "stream-shift": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
-      "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==",
+      "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==",
       "dev": true
     },
     "string-width": {
       }
     },
     "string.prototype.padend": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz",
-      "integrity": "sha1-86rvfBcZ8XDF6rHDK/eA2W4h8vA=",
+      "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==",
       "dev": true,
       "requires": {
-        "define-properties": "^1.1.2",
-        "es-abstract": "^1.4.3",
-        "function-bind": "^1.0.2"
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.0-next.1"
       }
     },
-    "string_decoder": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
-      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+    "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==",
+      "dev": true,
+      "requires": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.5"
+      }
+    },
+    "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==",
       "dev": true,
       "requires": {
-        "safe-buffer": "~5.1.0"
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.5"
       }
     },
     "strip-ansi": {
         "ansi-regex": "^4.1.0"
       }
     },
+    "strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+      "dev": true
+    },
     "supports-color": {
       "version": "5.5.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
         "has-flag": "^3.0.0"
       }
     },
-    "tapable": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
-      "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
-      "dev": true
-    },
-    "terser": {
-      "version": "4.8.0",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz",
-      "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==",
-      "dev": true,
-      "requires": {
-        "commander": "^2.20.0",
-        "source-map": "~0.6.1",
-        "source-map-support": "~0.5.12"
-      },
-      "dependencies": {
-        "source-map": {
-          "version": "0.6.1",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-          "dev": true
-        }
-      }
-    },
-    "terser-webpack-plugin": {
-      "version": "1.4.4",
-      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.4.tgz",
-      "integrity": "sha512-U4mACBHIegmfoEe5fdongHESNJWqsGU+W0S/9+BmYGVQDw1+c2Ow05TpMhxjPK1sRb7cuYq1BPl1e5YHJMTCqA==",
-      "dev": true,
-      "requires": {
-        "cacache": "^12.0.2",
-        "find-cache-dir": "^2.1.0",
-        "is-wsl": "^1.1.0",
-        "schema-utils": "^1.0.0",
-        "serialize-javascript": "^3.1.0",
-        "source-map": "^0.6.1",
-        "terser": "^4.1.2",
-        "webpack-sources": "^1.4.0",
-        "worker-farm": "^1.7.0"
-      },
-      "dependencies": {
-        "source-map": {
-          "version": "0.6.1",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-          "dev": true
-        }
-      }
-    },
-    "through2": {
-      "version": "2.0.5",
-      "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
-      "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
-      "dev": true,
-      "requires": {
-        "readable-stream": "~2.3.6",
-        "xtend": "~4.0.1"
-      }
-    },
-    "timers-browserify": {
-      "version": "2.0.11",
-      "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.11.tgz",
-      "integrity": "sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ==",
-      "dev": true,
-      "requires": {
-        "setimmediate": "^1.0.4"
-      }
-    },
     "tiny-emitter": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
       "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
     },
-    "to-arraybuffer": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
-      "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=",
-      "dev": true
-    },
-    "to-object-path": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
-      "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=",
-      "dev": true,
-      "requires": {
-        "kind-of": "^3.0.2"
-      },
-      "dependencies": {
-        "kind-of": {
-          "version": "3.2.2",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-          "dev": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        }
-      }
-    },
-    "to-regex": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
-      "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==",
-      "dev": true,
-      "requires": {
-        "define-property": "^2.0.2",
-        "extend-shallow": "^3.0.2",
-        "regex-not": "^1.0.2",
-        "safe-regex": "^1.1.0"
-      }
-    },
     "to-regex-range": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
         "is-number": "^7.0.0"
       }
     },
-    "tslib": {
-      "version": "1.13.0",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz",
-      "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==",
-      "dev": true
-    },
-    "tty-browserify": {
-      "version": "0.0.0",
-      "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
-      "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=",
-      "dev": true
-    },
-    "typedarray": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
-      "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
-      "dev": true
-    },
     "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=="
     },
-    "union-value": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
-      "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
-      "dev": true,
-      "requires": {
-        "arr-union": "^3.1.0",
-        "get-value": "^2.0.6",
-        "is-extendable": "^0.1.1",
-        "set-value": "^2.0.1"
-      }
-    },
-    "unique-filename": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
-      "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
-      "dev": true,
-      "requires": {
-        "unique-slug": "^2.0.0"
-      }
-    },
-    "unique-slug": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
-      "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
-      "dev": true,
-      "requires": {
-        "imurmurhash": "^0.1.4"
-      }
-    },
-    "unset-value": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
-      "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=",
-      "dev": true,
-      "requires": {
-        "has-value": "^0.3.1",
-        "isobject": "^3.0.0"
-      },
-      "dependencies": {
-        "has-value": {
-          "version": "0.3.1",
-          "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz",
-          "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=",
-          "dev": true,
-          "requires": {
-            "get-value": "^2.0.3",
-            "has-values": "^0.1.4",
-            "isobject": "^2.0.0"
-          },
-          "dependencies": {
-            "isobject": {
-              "version": "2.1.0",
-              "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
-              "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=",
-              "dev": true,
-              "requires": {
-                "isarray": "1.0.0"
-              }
-            }
-          }
-        },
-        "has-values": {
-          "version": "0.1.4",
-          "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz",
-          "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=",
-          "dev": true
-        }
-      }
-    },
-    "upath": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
-      "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
-      "dev": true,
-      "optional": true
-    },
-    "uri-js": {
-      "version": "4.2.2",
-      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
-      "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
-      "dev": true,
-      "requires": {
-        "punycode": "^2.1.0"
-      }
-    },
-    "urix": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
-      "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=",
-      "dev": true
-    },
-    "url": {
-      "version": "0.11.0",
-      "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
-      "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=",
-      "dev": true,
-      "requires": {
-        "punycode": "1.3.2",
-        "querystring": "0.2.0"
-      },
-      "dependencies": {
-        "punycode": {
-          "version": "1.3.2",
-          "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
-          "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=",
-          "dev": true
-        }
-      }
-    },
-    "use": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
-      "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
-      "dev": true
-    },
-    "util": {
-      "version": "0.11.1",
-      "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
-      "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==",
-      "dev": true,
-      "requires": {
-        "inherits": "2.0.3"
-      },
-      "dependencies": {
-        "inherits": {
-          "version": "2.0.3",
-          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-          "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
-          "dev": true
-        }
-      }
-    },
-    "util-deprecate": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
-      "dev": true
-    },
-    "v8-compile-cache": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz",
-      "integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==",
-      "dev": true
-    },
     "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",
         "spdx-expression-parse": "^3.0.0"
       }
     },
-    "vm-browserify": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
-      "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
-      "dev": true
-    },
-    "vue": {
-      "version": "2.6.11",
-      "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz",
-      "integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ=="
-    },
-    "vuedraggable": {
-      "version": "2.23.2",
-      "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz",
-      "integrity": "sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==",
-      "requires": {
-        "sortablejs": "^1.10.1"
-      }
-    },
-    "watchpack": {
-      "version": "1.7.2",
-      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.2.tgz",
-      "integrity": "sha512-ymVbbQP40MFTp+cNMvpyBpBtygHnPzPkHqoIwRRj/0B8KhqQwV8LaKjtbaxF2lK4vl8zN9wCxS46IFCU5K4W0g==",
-      "dev": true,
-      "requires": {
-        "chokidar": "^3.4.0",
-        "graceful-fs": "^4.1.2",
-        "neo-async": "^2.5.0",
-        "watchpack-chokidar2": "^2.0.0"
-      },
-      "dependencies": {
-        "chokidar": {
-          "version": "3.4.0",
-          "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.0.tgz",
-          "integrity": "sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "anymatch": "~3.1.1",
-            "braces": "~3.0.2",
-            "fsevents": "~2.1.2",
-            "glob-parent": "~5.1.0",
-            "is-binary-path": "~2.1.0",
-            "is-glob": "~4.0.1",
-            "normalize-path": "~3.0.0",
-            "readdirp": "~3.4.0"
-          }
-        },
-        "readdirp": {
-          "version": "3.4.0",
-          "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
-          "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "picomatch": "^2.2.1"
-          }
-        }
-      }
-    },
-    "watchpack-chokidar2": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz",
-      "integrity": "sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA==",
-      "dev": true,
-      "optional": true,
-      "requires": {
-        "chokidar": "^2.1.8"
-      },
-      "dependencies": {
-        "anymatch": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz",
-          "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "micromatch": "^3.1.4",
-            "normalize-path": "^2.1.1"
-          },
-          "dependencies": {
-            "normalize-path": {
-              "version": "2.1.1",
-              "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
-              "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=",
-              "dev": true,
-              "optional": true,
-              "requires": {
-                "remove-trailing-separator": "^1.0.1"
-              }
-            }
-          }
-        },
-        "binary-extensions": {
-          "version": "1.13.1",
-          "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
-          "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==",
-          "dev": true,
-          "optional": true
-        },
-        "braces": {
-          "version": "2.3.2",
-          "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
-          "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "arr-flatten": "^1.1.0",
-            "array-unique": "^0.3.2",
-            "extend-shallow": "^2.0.1",
-            "fill-range": "^4.0.0",
-            "isobject": "^3.0.1",
-            "repeat-element": "^1.1.2",
-            "snapdragon": "^0.8.1",
-            "snapdragon-node": "^2.0.1",
-            "split-string": "^3.0.2",
-            "to-regex": "^3.0.1"
-          }
-        },
-        "chokidar": {
-          "version": "2.1.8",
-          "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
-          "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "anymatch": "^2.0.0",
-            "async-each": "^1.0.1",
-            "braces": "^2.3.2",
-            "fsevents": "^1.2.7",
-            "glob-parent": "^3.1.0",
-            "inherits": "^2.0.3",
-            "is-binary-path": "^1.0.0",
-            "is-glob": "^4.0.0",
-            "normalize-path": "^3.0.0",
-            "path-is-absolute": "^1.0.0",
-            "readdirp": "^2.2.1",
-            "upath": "^1.1.1"
-          }
-        },
-        "extend-shallow": {
-          "version": "2.0.1",
-          "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
-          "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "is-extendable": "^0.1.0"
-          }
-        },
-        "fill-range": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
-          "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "extend-shallow": "^2.0.1",
-            "is-number": "^3.0.0",
-            "repeat-string": "^1.6.1",
-            "to-regex-range": "^2.1.0"
-          }
-        },
-        "fsevents": {
-          "version": "1.2.13",
-          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
-          "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
-          "dev": true,
-          "optional": true
-        },
-        "glob-parent": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
-          "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "is-glob": "^3.1.0",
-            "path-dirname": "^1.0.0"
-          },
-          "dependencies": {
-            "is-glob": {
-              "version": "3.1.0",
-              "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
-              "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=",
-              "dev": true,
-              "optional": true,
-              "requires": {
-                "is-extglob": "^2.1.0"
-              }
-            }
-          }
-        },
-        "is-binary-path": {
-          "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz",
-          "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "binary-extensions": "^1.0.0"
-          }
-        },
-        "is-number": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
-          "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "kind-of": "^3.0.2"
-          }
-        },
-        "kind-of": {
-          "version": "3.2.2",
-          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "is-buffer": "^1.1.5"
-          }
-        },
-        "readdirp": {
-          "version": "2.2.1",
-          "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz",
-          "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "graceful-fs": "^4.1.11",
-            "micromatch": "^3.1.10",
-            "readable-stream": "^2.0.2"
-          }
-        },
-        "to-regex-range": {
-          "version": "2.1.1",
-          "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
-          "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "is-number": "^3.0.0",
-            "repeat-string": "^1.6.1"
-          }
-        }
-      }
-    },
-    "webpack": {
-      "version": "4.43.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.43.0.tgz",
-      "integrity": "sha512-GW1LjnPipFW2Y78OOab8NJlCflB7EFskMih2AHdvjbpKMeDJqEgSx24cXXXiPS65+WSwVyxtDsJH6jGX2czy+g==",
-      "dev": true,
-      "requires": {
-        "@webassemblyjs/ast": "1.9.0",
-        "@webassemblyjs/helper-module-context": "1.9.0",
-        "@webassemblyjs/wasm-edit": "1.9.0",
-        "@webassemblyjs/wasm-parser": "1.9.0",
-        "acorn": "^6.4.1",
-        "ajv": "^6.10.2",
-        "ajv-keywords": "^3.4.1",
-        "chrome-trace-event": "^1.0.2",
-        "enhanced-resolve": "^4.1.0",
-        "eslint-scope": "^4.0.3",
-        "json-parse-better-errors": "^1.0.2",
-        "loader-runner": "^2.4.0",
-        "loader-utils": "^1.2.3",
-        "memory-fs": "^0.4.1",
-        "micromatch": "^3.1.10",
-        "mkdirp": "^0.5.3",
-        "neo-async": "^2.6.1",
-        "node-libs-browser": "^2.2.1",
-        "schema-utils": "^1.0.0",
-        "tapable": "^1.1.3",
-        "terser-webpack-plugin": "^1.4.3",
-        "watchpack": "^1.6.1",
-        "webpack-sources": "^1.4.1"
-      }
-    },
-    "webpack-cli": {
-      "version": "3.3.12",
-      "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-3.3.12.tgz",
-      "integrity": "sha512-NVWBaz9k839ZH/sinurM+HcDvJOTXwSjYp1ku+5XKeOC03z8v5QitnK/x+lAxGXFyhdayoIf/GOpv85z3/xPag==",
-      "dev": true,
-      "requires": {
-        "chalk": "^2.4.2",
-        "cross-spawn": "^6.0.5",
-        "enhanced-resolve": "^4.1.1",
-        "findup-sync": "^3.0.0",
-        "global-modules": "^2.0.0",
-        "import-local": "^2.0.0",
-        "interpret": "^1.4.0",
-        "loader-utils": "^1.4.0",
-        "supports-color": "^6.1.0",
-        "v8-compile-cache": "^2.1.1",
-        "yargs": "^13.3.2"
-      },
-      "dependencies": {
-        "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,
-          "requires": {
-            "ansi-styles": "^3.2.1",
-            "escape-string-regexp": "^1.0.5",
-            "supports-color": "^5.3.0"
-          },
-          "dependencies": {
-            "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,
-              "requires": {
-                "has-flag": "^3.0.0"
-              }
-            }
-          }
-        },
-        "supports-color": {
-          "version": "6.1.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
-          "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
-          "dev": true,
-          "requires": {
-            "has-flag": "^3.0.0"
-          }
-        }
-      }
-    },
-    "webpack-sources": {
-      "version": "1.4.3",
-      "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
-      "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
-      "dev": true,
-      "requires": {
-        "source-list-map": "^2.0.0",
-        "source-map": "~0.6.1"
-      },
-      "dependencies": {
-        "source-map": {
-          "version": "0.6.1",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-          "dev": true
-        }
-      }
-    },
     "which": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
       "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
       "dev": true
     },
-    "worker-farm": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz",
-      "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==",
-      "dev": true,
-      "requires": {
-        "errno": "~0.1.7"
-      }
-    },
     "wrap-ansi": {
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
         "strip-ansi": "^5.0.0"
       }
     },
-    "wrappy": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
-      "dev": true
-    },
     "ws": {
-      "version": "6.2.1",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
-      "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
+      "version": "7.4.6",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
+      "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
       "dev": true,
-      "requires": {
-        "async-limiter": "~1.0.0"
-      }
-    },
-    "xtend": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
-      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
-      "dev": true
+      "requires": {}
     },
     "y18n": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
-      "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
-      "dev": true
-    },
-    "yallist": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
-      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz",
+      "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
       "dev": true
     },
     "yargs": {
index 7462dab92516b47a4859e5294950fd3ca0fb480e..d2740cd815fee3fd802b99d7a268712597640562 100644 (file)
@@ -4,9 +4,9 @@
     "build:css:dev": "sass ./resources/sass:./public/dist",
     "build:css:watch": "sass ./resources/sass:./public/dist --watch",
     "build:css:production": "sass ./resources/sass:./public/dist -s compressed",
-    "build:js:dev": "webpack",
-    "build:js:watch": "webpack --watch",
-    "build:js:production": "NODE_ENV=production webpack",
+    "build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
+    "build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
+    "build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
     "build": "npm-run-all --parallel build:*:dev",
     "production": "npm-run-all --parallel build:*:production",
     "dev": "npm-run-all --parallel watch livereload",
     "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
   },
   "devDependencies": {
-    "livereload": "^0.9.1",
+    "chokidar-cli": "^3.0.0",
+    "esbuild": "0.12.22",
+    "livereload": "^0.9.3",
     "npm-run-all": "^4.1.5",
-    "sass": "^1.26.9",
-    "webpack": "^4.43.0",
-    "webpack-cli": "^3.3.12"
+    "punycode": "^2.1.1",
+    "sass": "^1.38.0"
   },
   "dependencies": {
-    "clipboard": "^2.0.6",
-    "codemirror": "^5.55.0",
-    "dropzone": "^5.7.1",
-    "markdown-it": "^11.0.0",
+    "clipboard": "^2.0.8",
+    "codemirror": "^5.62.3",
+    "dropzone": "^5.9.2",
+    "markdown-it": "^12.2.0",
     "markdown-it-task-lists": "^2.1.1",
-    "sortablejs": "^1.10.2",
-    "vue": "^2.6.11",
-    "vuedraggable": "^2.23.2"
-  },
-  "browser": {
-    "vue": "vue/dist/vue.common.js"
+    "sortablejs": "^1.14.0"
   }
 }
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644 (file)
index ccde280..0000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0"?>
-<ruleset name="PHP_CodeSniffer">
-    <description>The coding standard for BookStack.</description>
-    <file>app</file>
-    <exclude-pattern>*/migrations/*</exclude-pattern>
-    <exclude-pattern>*/tests/*</exclude-pattern>
-    <arg value="np"/>
-    <rule ref="PSR2"/>
-</ruleset>
\ No newline at end of file
index 85538c446a2438d7df122dd570b5141c64d1dbec..7e0da05d42fb33a8a401af585609c53ee1af6659 100644 (file)
@@ -1,55 +1,65 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<phpunit backupGlobals="false"
+<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="bootstrap/init.php"
+         bootstrap="vendor/autoload.php"
          colors="true"
          convertErrorsToExceptions="true"
          convertNoticesToExceptions="true"
          convertWarningsToExceptions="true"
          processIsolation="false"
          stopOnFailure="false">
-    <testsuites>
-        <testsuite name="Application Test Suite">
-            <directory>./tests/</directory>
-        </testsuite>
-    </testsuites>
-    <filter>
-        <whitelist>
-            <directory suffix=".php">app/</directory>
-        </whitelist>
-    </filter>
-    <php>
-        <server name="APP_ENV" value="testing"/>
-        <server name="APP_DEBUG" value="false"/>
-        <server name="APP_LANG" value="en"/>
-        <server name="APP_THEME" value="none"/>
-        <server name="APP_AUTO_LANG_PUBLIC" value="true"/>
-        <server name="CACHE_DRIVER" value="array"/>
-        <server name="SESSION_DRIVER" value="array"/>
-        <server name="QUEUE_CONNECTION" value="sync"/>
-        <server name="DB_CONNECTION" value="mysql_testing"/>
-        <server name="BCRYPT_ROUNDS" value="4"/>
-        <server name="MAIL_DRIVER" value="array"/>
-        <server name="LOG_CHANNEL" value="single"/>
-        <server name="AUTH_METHOD" value="standard"/>
-        <server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
-        <server name="AVATAR_URL" value=""/>
-        <server name="LDAP_VERSION" value="3"/>
-        <server name="STORAGE_TYPE" value="local"/>
-        <server name="STORAGE_ATTACHMENT_TYPE" value="local"/>
-        <server name="STORAGE_IMAGE_TYPE" value="local"/>
-        <server name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
-        <server name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
-        <server name="GITHUB_AUTO_REGISTER" value=""/>
-        <server name="GITHUB_AUTO_CONFIRM_EMAIL" value=""/>
-        <server name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
-        <server name="GOOGLE_APP_SECRET" value="aaaaaaaaaaaaaa"/>
-        <server name="GOOGLE_AUTO_REGISTER" value=""/>
-        <server name="GOOGLE_AUTO_CONFIRM_EMAIL" value=""/>
-        <server name="GOOGLE_SELECT_ACCOUNT" value=""/>
-        <server name="APP_URL" value="http://bookstack.dev"/>
-        <server name="DEBUGBAR_ENABLED" value="false"/>
-        <server name="SAML2_ENABLED" value="false"/>
-        <server name="API_REQUESTS_PER_MIN" value="180"/>
-    </php>
+  <coverage>
+    <include>
+      <directory suffix=".php">app/</directory>
+    </include>
+  </coverage>
+  <testsuites>
+    <testsuite name="Application Test Suite">
+      <directory>./tests/</directory>
+    </testsuite>
+  </testsuites>
+  <php>
+    <server name="APP_ENV" value="testing"/>
+    <server name="APP_DEBUG" value="false"/>
+    <server name="APP_LANG" value="en"/>
+    <server name="APP_THEME" value="none"/>
+    <server name="APP_AUTO_LANG_PUBLIC" value="true"/>
+    <server name="APP_URL" value="http://bookstack.dev"/>
+    <server name="ALLOWED_IFRAME_HOSTS" value=""/>
+    <server name="CACHE_DRIVER" value="array"/>
+    <server name="SESSION_DRIVER" value="array"/>
+    <server name="QUEUE_CONNECTION" value="sync"/>
+    <server name="DB_CONNECTION" value="mysql_testing"/>
+    <server name="BCRYPT_ROUNDS" value="4"/>
+    <server name="MAIL_DRIVER" value="array"/>
+    <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"/>
+    <server name="SESSION_SECURE_COOKIE" value="null"/>
+    <server name="STORAGE_TYPE" value="local"/>
+    <server name="STORAGE_ATTACHMENT_TYPE" value="local"/>
+    <server name="STORAGE_IMAGE_TYPE" value="local"/>
+    <server name="GITHUB_APP_ID" value="aaaaaaaaaaaaaa"/>
+    <server name="GITHUB_APP_SECRET" value="aaaaaaaaaaaaaa"/>
+    <server name="GITHUB_AUTO_REGISTER" value=""/>
+    <server name="GITHUB_AUTO_CONFIRM_EMAIL" value=""/>
+    <server name="GOOGLE_APP_ID" value="aaaaaaaaaaaaaa"/>
+    <server name="GOOGLE_APP_SECRET" value="aaaaaaaaaaaaaa"/>
+    <server name="GOOGLE_AUTO_REGISTER" value=""/>
+    <server name="GOOGLE_AUTO_CONFIRM_EMAIL" value=""/>
+    <server name="GOOGLE_SELECT_ACCOUNT" value=""/>
+    <server name="DEBUGBAR_ENABLED" value="false"/>
+    <server name="SAML2_ENABLED" value="false"/>
+    <server name="API_REQUESTS_PER_MIN" value="180"/>
+    <server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
+    <server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
+    <server name="WKHTMLTOPDF" value="false"/>
+    <server name="APP_DEFAULT_DARK_MODE" value="false"/>
+  </php>
 </phpunit>
index abe87b39de7a73abd817cedd264d3c1f7b9ec17b..3aec5e27e5db801fa9e321c0a97acbb49e10908f 100644 (file)
 
     # Redirect Trailing Slashes If Not A Folder...
     RewriteCond %{REQUEST_FILENAME} !-d
-    RewriteRule ^(.*)/$ /$1 [L,R=301]
+    RewriteCond %{REQUEST_URI} (.+)/$
+    RewriteRule ^ %1 [L,R=301]
 
-    # Handle Front Controller...
+    # Send Requests To Front Controller...
     RewriteCond %{REQUEST_FILENAME} !-d
     RewriteCond %{REQUEST_FILENAME} !-f
     RewriteRule ^ index.php [L]
index 8205764728cdb1dc6bd8bdfb78f20ebec5525ac3..7e4ef97c7074672e42069c9595e217005224892a 100644 (file)
@@ -1,25 +1,25 @@
 <?php
 
 /**
- * Laravel - A PHP Framework For Web Artisans
+ * Laravel - A PHP Framework For Web Artisans.
  *
- * @package  Laravel
  * @author   Taylor Otwell <[email protected]>
  */
-
 define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| 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.
 |
 */
 
-require __DIR__.'/../bootstrap/init.php';
+require __DIR__ . '/../vendor/autoload.php';
 
 /*
 |--------------------------------------------------------------------------
@@ -33,7 +33,7 @@ require __DIR__.'/../bootstrap/init.php';
 |
 */
 
-$app = require_once __DIR__.'/../bootstrap/app.php';
+$app = require_once __DIR__ . '/../bootstrap/app.php';
 $app->alias('request', \BookStack\Http\Request::class);
 
 /*
@@ -56,4 +56,4 @@ $response = $kernel->handle(
 
 $response->send();
 
-$kernel->terminate($request, $response);
\ No newline at end of file
+$kernel->terminate($request, $response);
index 2c68d094c1e5c20c3dc1167072db1c7eb1b2d5e5..cb17a1aae478819e7c92ad1b21df36d2a0ae6183 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -3,8 +3,10 @@
 [![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)
 [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
+[![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)
-[![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
+[![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/.
 
@@ -41,17 +43,17 @@ Below is a high-level road map view for BookStack to provide a sense of directio
 
 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.
 
-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. 
+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](http://eepurl.com/cmmq5j).
+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).
 
 ## 🛠️ 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:
 
-* [Node.js](https://nodejs.org/en/) v10.0+
+* [Node.js](https://nodejs.org/en/) v12.0+
 
-This project uses SASS for CSS development and this is built, along with the JavaScript, using webpack. The below npm commands can be used to install the dependencies & run the build tasks:
+This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
 
 ``` bash
 # Install NPM Dependencies
@@ -80,7 +82,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
 
@@ -93,21 +96,36 @@ To get started, make sure you meet the following requirements:
 
 If all the conditions are met, you can proceed with the following steps:
 
-1. Install PHP/Composer dependencies with **`docker-compose run app composer install`** (first time can take a while because the image has to be built).
-2. **Copy `.env.example` to `.env`** and change `APP_KEY` to a random 32 char string.
-3. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
-4. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
-5. **Run `docker-compose up`** and wait until all database migrations have been done.
-6. You can now login with `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
+1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
+2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
+3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
+4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
+5. You can now login with `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
 
 If needed, You'll be able to run any artisan commands via docker-compose like so:
 
- ```shell script
-docker-compose run app php artisan list 
+```bash
+docker-compose run app php artisan list
 ```
 
 The docker-compose setup runs an instance of [MailHog](https://github.com/mailhog/MailHog) and sets environment variables to redirect any BookStack-sent emails to MailHog. You can view this mail via the MailHog web interface on `localhost:8025`. You can change the port MailHog is accessible on by setting a `DEV_MAIL_PORT` environment variable.
 
+#### Running tests
+
+After starting the general development Docker, migrate & seed the testing database:
+
+ ```bash
+# This only needs to be done once
+docker-compose run app php artisan migrate --database=mysql_testing
+docker-compose run app php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
+```
+
+Once the database has been migrated & seeded, you can run the tests like so:
+
+ ```bash
+docker-compose run app php vendor/bin/phpunit
+```
+
 ## 🌎 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.
@@ -122,7 +140,7 @@ 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/assets`. 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 `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.
 
 The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
 
@@ -130,7 +148,7 @@ The project's code of conduct [can be found here](https://github.com/BookStackAp
 
 Security information for administering a BookStack instance can be found on the [documentation site here](https://www.bookstackapp.com/docs/admin/security/).
 
-If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](http://eepurl.com/glIh8z).
+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).
 
@@ -157,8 +175,7 @@ These are the great open-source projects used to help build BookStack:
 * [Laravel](http://laravel.com/)
 * [TinyMCE](https://www.tinymce.com/)
 * [CodeMirror](https://codemirror.net)
-* [Vue.js](http://vuejs.org/)
-* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
+* [Sortable](https://github.com/SortableJS/Sortable)
 * [Google Material Icons](https://material.io/icons/)
 * [Dropzone.js](http://www.dropzonejs.com/)
 * [clipboard.js](https://clipboardjs.com/)
@@ -169,6 +186,10 @@ These are the great open-source projects used to help build BookStack:
     * [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
     * [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
 * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
-* [Draw.io](https://github.com/jgraph/drawio)
-* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
-* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
\ No newline at end of file
+* [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)
\ 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/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js
new file mode 100644 (file)
index 0000000..2feb3d5
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * AjaxDelete
+ * @extends {Component}
+ */
+import {onSelect} from "../services/dom";
+
+class AjaxDeleteRow {
+    setup() {
+        this.row = this.$el;
+        this.url = this.$opts.url;
+        this.deleteButtons = this.$manyRefs.delete;
+
+        onSelect(this.deleteButtons, this.runDelete.bind(this));
+    }
+
+    runDelete() {
+        this.row.style.opacity = '0.7';
+        this.row.style.pointerEvents = 'none';
+
+        window.$http.delete(this.url).then(resp => {
+            if (typeof resp.data === 'object' && resp.data.message) {
+                window.$events.emit('success', resp.data.message);
+            }
+            this.row.remove();
+        }).catch(err => {
+            this.row.style.opacity = null;
+            this.row.style.pointerEvents = null;
+        });
+    }
+}
+
+export default AjaxDeleteRow;
\ No newline at end of file
diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js
new file mode 100644 (file)
index 0000000..91029d0
--- /dev/null
@@ -0,0 +1,82 @@
+import {onEnterPress, onSelect} from "../services/dom";
+
+/**
+ * Ajax Form
+ * Will handle button clicks or input enter press events and submit
+ * the data over ajax. Will always expect a partial HTML view to be returned.
+ * Fires an 'ajax-form-success' event when submitted successfully.
+ *
+ * Will handle a real form if that's what the component is added to
+ * otherwise will act as a fake form element.
+ *
+ * @extends {Component}
+ */
+class AjaxForm {
+    setup() {
+        this.container = this.$el;
+        this.responseContainer = this.container;
+        this.url = this.$opts.url;
+        this.method = this.$opts.method || 'post';
+        this.successMessage = this.$opts.successMessage;
+        this.submitButtons = this.$manyRefs.submit || [];
+
+        if (this.$opts.responseContainer) {
+            this.responseContainer = this.container.closest(this.$opts.responseContainer);
+        }
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+
+        if (this.container.tagName === 'FORM') {
+            this.container.addEventListener('submit', this.submitRealForm.bind(this));
+            return;
+        }
+
+        onEnterPress(this.container, event => {
+            this.submitFakeForm();
+            event.preventDefault();
+        });
+
+        this.submitButtons.forEach(button => onSelect(button, this.submitFakeForm.bind(this)));
+    }
+
+    submitFakeForm() {
+        const fd = new FormData();
+        const inputs = this.container.querySelectorAll(`[name]`);
+        for (const input of inputs) {
+            fd.append(input.getAttribute('name'), input.value);
+        }
+        this.submit(fd);
+    }
+
+    submitRealForm(event) {
+        event.preventDefault();
+        const fd = new FormData(this.container);
+        this.submit(fd);
+    }
+
+    async submit(formData) {
+        this.responseContainer.style.opacity = '0.7';
+        this.responseContainer.style.pointerEvents = 'none';
+
+        try {
+            const resp = await window.$http[this.method.toLowerCase()](this.url, formData);
+            this.$emit('success', {formData});
+            this.responseContainer.innerHTML = resp.data;
+            if (this.successMessage) {
+                window.$events.emit('success', this.successMessage);
+            }
+        } catch (err) {
+            this.responseContainer.innerHTML = err.data;
+        }
+
+        window.components.init(this.responseContainer);
+        this.responseContainer.style.opacity = null;
+        this.responseContainer.style.pointerEvents = null;
+    }
+
+}
+
+export default AjaxForm;
\ 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
diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js
new file mode 100644 (file)
index 0000000..6dcfe9f
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ * Attachments
+ * @extends {Component}
+ */
+import {showLoading} from "../services/dom";
+
+class Attachments {
+
+    setup() {
+        this.container = this.$el;
+        this.pageId = this.$opts.pageId;
+        this.editContainer = this.$refs.editContainer;
+        this.listContainer = this.$refs.listContainer;
+        this.mainTabs = this.$refs.mainTabs;
+        this.list = this.$refs.list;
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        const reloadListBound = this.reloadList.bind(this);
+        this.container.addEventListener('dropzone-success', reloadListBound);
+        this.container.addEventListener('ajax-form-success', reloadListBound);
+
+        this.container.addEventListener('sortable-list-sort', event => {
+            this.updateOrder(event.detail.ids);
+        });
+
+        this.container.addEventListener('event-emit-select-edit', event => {
+            this.startEdit(event.detail.id);
+        });
+
+        this.container.addEventListener('event-emit-select-edit-back', event => {
+            this.stopEdit();
+        });
+
+        this.container.addEventListener('event-emit-select-insert', event => {
+            const insertContent = event.target.closest('[data-drag-content]').getAttribute('data-drag-content');
+            const contentTypes = JSON.parse(insertContent);
+            window.$events.emit('editor::insert', {
+                html: contentTypes['text/html'],
+                markdown: contentTypes['text/plain'],
+            });
+        });
+    }
+
+    reloadList() {
+        this.stopEdit();
+        this.mainTabs.components.tabs.show('items');
+        window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
+            this.list.innerHTML = resp.data;
+            window.components.init(this.list);
+        });
+    }
+
+    updateOrder(idOrder) {
+        window.$http.put(`/attachments/sort/page/${this.pageId}`, {order: idOrder}).then(resp => {
+            window.$events.emit('success', resp.data.message);
+        });
+    }
+
+    async startEdit(id) {
+        this.editContainer.classList.remove('hidden');
+        this.listContainer.classList.add('hidden');
+
+        showLoading(this.editContainer);
+        const resp = await window.$http.get(`/attachments/edit/${id}`);
+        this.editContainer.innerHTML = resp.data;
+        window.components.init(this.editContainer);
+    }
+
+    stopEdit() {
+        this.editContainer.classList.add('hidden');
+        this.listContainer.classList.remove('hidden');
+    }
+
+}
+
+export default Attachments;
\ No newline at end of file
index b0d64ad172f1e0abe7b4e457a415c588a083da1c..2b94ca4a7a19a68ff82b31345efbc52fc28dc56e 100644 (file)
@@ -1,4 +1,4 @@
-import {Sortable, MultiDrag} from "sortablejs";
+import Sortable from "sortablejs";
 
 // Auto sort control
 const sortOperations = {
@@ -43,7 +43,6 @@ class BookSort {
         this.input = elem.querySelector('[book-sort-input]');
 
         const initialSortBox = elem.querySelector('.sort-box');
-        Sortable.mount(new MultiDrag());
         this.setupBookSortable(initialSortBox);
         this.setupSortPresets();
 
diff --git a/resources/js/components/breadcrumb-listing.js b/resources/js/components/breadcrumb-listing.js
deleted file mode 100644 (file)
index 7f4344b..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-class BreadcrumbListing {
-
-    constructor(elem) {
-        this.elem = elem;
-        this.searchInput = elem.querySelector('input');
-        this.loadingElem = elem.querySelector('.loading-container');
-        this.entityListElem = elem.querySelector('.breadcrumb-listing-entity-list');
-
-        // this.loadingElem.style.display = 'none';
-        const entityDescriptor = elem.getAttribute('breadcrumb-listing').split(':');
-        this.entityType = entityDescriptor[0];
-        this.entityId = Number(entityDescriptor[1]);
-
-        this.elem.addEventListener('show', this.onShow.bind(this));
-        this.searchInput.addEventListener('input', this.onSearch.bind(this));
-    }
-
-    onShow() {
-        this.loadEntityView();
-    }
-
-    onSearch() {
-        const input = this.searchInput.value.toLowerCase().trim();
-        const listItems = this.entityListElem.querySelectorAll('.entity-list-item');
-        for (let listItem of listItems) {
-            const match = !input || listItem.textContent.toLowerCase().includes(input);
-            listItem.style.display = match ? 'flex' : 'none';
-            listItem.classList.toggle('hidden', !match);
-        }
-    }
-
-    loadEntityView() {
-        this.toggleLoading(true);
-
-        const params = {
-            'entity_id': this.entityId,
-            'entity_type': this.entityType,
-        };
-
-        window.$http.get('/search/entity/siblings', params).then(resp => {
-            this.entityListElem.innerHTML = resp.data;
-        }).catch(err => {
-            console.error(err);
-        }).then(() => {
-            this.toggleLoading(false);
-            this.onSearch();
-        });
-    }
-
-    toggleLoading(show = false) {
-        this.loadingElem.style.display = show ? 'block' : 'none';
-    }
-
-}
-
-export default BreadcrumbListing;
\ No newline at end of file
diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js
new file mode 100644 (file)
index 0000000..e2d55f9
--- /dev/null
@@ -0,0 +1,80 @@
+import {debounce} from "../services/util";
+
+class DropdownSearch {
+
+    setup() {
+        this.elem = this.$el;
+        this.searchInput = this.$refs.searchInput;
+        this.loadingElem = this.$refs.loading;
+        this.listContainerElem = this.$refs.listContainer;
+
+        this.localSearchSelector = this.$opts.localSearchSelector;
+        this.url = this.$opts.url;
+
+        this.elem.addEventListener('show', this.onShow.bind(this));
+        this.searchInput.addEventListener('input', this.onSearch.bind(this));
+
+        this.runAjaxSearch = debounce(this.runAjaxSearch, 300, false);
+    }
+
+    onShow() {
+        this.loadList();
+    }
+
+    onSearch() {
+        const input = this.searchInput.value.toLowerCase().trim();
+        if (this.localSearchSelector) {
+            this.runLocalSearch(input);
+        } else {
+            this.toggleLoading(true);
+            this.listContainerElem.innerHTML = '';
+            this.runAjaxSearch(input);
+        }
+    }
+
+    runAjaxSearch(searchTerm) {
+        this.loadList(searchTerm);
+    }
+
+    runLocalSearch(searchTerm) {
+        const listItems = this.listContainerElem.querySelectorAll(this.localSearchSelector);
+        for (let listItem of listItems) {
+            const match = !searchTerm || listItem.textContent.toLowerCase().includes(searchTerm);
+            listItem.style.display = match ? 'flex' : 'none';
+            listItem.classList.toggle('hidden', !match);
+        }
+    }
+
+    async loadList(searchTerm = '') {
+        this.listContainerElem.innerHTML = '';
+        this.toggleLoading(true);
+
+        try {
+            const resp = await window.$http.get(this.getAjaxUrl(searchTerm));
+            this.listContainerElem.innerHTML = resp.data;
+        } catch (err) {
+            console.error(err);
+        }
+
+        this.toggleLoading(false);
+        if (this.localSearchSelector) {
+            this.onSearch();
+        }
+    }
+
+    getAjaxUrl(searchTerm = null) {
+        if (!searchTerm) {
+            return this.url;
+        }
+
+        const joiner = this.url.includes('?') ? '&' : '?';
+        return `${this.url}${joiner}search=${encodeURIComponent(searchTerm)}`;
+    }
+
+    toggleLoading(show = false) {
+        this.loadingElem.style.display = show ? 'block' : 'none';
+    }
+
+}
+
+export default DropdownSearch;
\ No newline at end of file
index 7b1ce30556d41bde39b961065b47f87dcc5783f4..f761ecf011541590963caf66daf4b145f43c8cde 100644 (file)
@@ -12,11 +12,13 @@ class DropDown {
         this.menu = this.$refs.menu;
         this.toggle = this.$refs.toggle;
         this.moveMenu = this.$opts.moveMenu;
+        this.bubbleEscapes = this.$opts.bubbleEscapes === 'true';
 
         this.direction = (document.dir === 'rtl') ? 'right' : 'left';
         this.body = document.body;
         this.showing = false;
         this.setupListeners();
+        this.hide = this.hide.bind(this);
     }
 
     show(event = null) {
@@ -136,7 +138,9 @@ class DropDown {
             } else if (event.key === 'Escape') {
                 this.hide();
                 this.toggle.focus();
-                event.stopPropagation();
+                if (!this.bubbleEscapes) {
+                    event.stopPropagation();
+                }
             }
         };
         this.container.addEventListener('keydown', keyboardNavigation);
diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js
new file mode 100644 (file)
index 0000000..e7273df
--- /dev/null
@@ -0,0 +1,77 @@
+import DropZoneLib from "dropzone";
+import {fadeOut} from "../services/animations";
+
+/**
+ * Dropzone
+ * @extends {Component}
+ */
+class Dropzone {
+    setup() {
+        this.container = this.$el;
+        this.url = this.$opts.url;
+        this.successMessage = this.$opts.successMessage;
+        this.removeMessage = this.$opts.removeMessage;
+        this.uploadLimitMessage = this.$opts.uploadLimitMessage;
+        this.timeoutMessage = this.$opts.timeoutMessage;
+
+        const _this = this;
+        this.dz = new DropZoneLib(this.container, {
+            addRemoveLinks: true,
+            dictRemoveFile: this.removeMessage,
+            timeout: Number(window.uploadTimeout) || 60000,
+            maxFilesize: Number(window.uploadLimit) || 256,
+            url: this.url,
+            withCredentials: true,
+            init() {
+                this.dz = this;
+                this.dz.on('sending', _this.onSending.bind(_this));
+                this.dz.on('success', _this.onSuccess.bind(_this));
+                this.dz.on('error', _this.onError.bind(_this));
+            }
+        });
+    }
+
+    onSending(file, xhr, data) {
+
+        const token = window.document.querySelector('meta[name=token]').getAttribute('content');
+        data.append('_token', token);
+
+        xhr.ontimeout = (e) => {
+            this.dz.emit('complete', file);
+            this.dz.emit('error', file, this.timeoutMessage);
+        }
+    }
+
+    onSuccess(file, data) {
+        this.$emit('success', {file, data});
+
+        if (this.successMessage) {
+            window.$events.emit('success', this.successMessage);
+        }
+
+        fadeOut(file.previewElement, 800, () => {
+            this.dz.removeFile(file);
+        });
+    }
+
+    onError(file, errorMessage, xhr) {
+        this.$emit('error', {file, errorMessage, xhr});
+
+        const setMessage = (message) => {
+            const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
+            messsageEl.textContent = message;
+        }
+
+        if (xhr && xhr.status === 413) {
+            setMessage(this.uploadLimitMessage);
+        } else if (errorMessage.file) {
+            setMessage(errorMessage.file);
+        }
+    }
+
+    removeAll() {
+        this.dz.removeAllFiles(true);
+    }
+}
+
+export default Dropzone;
\ No newline at end of file
index 58879a20c0c5d8c76534e3af2bfc5a2da59c9e46..6d9d06f860329b402c9166db51ffc646537ca988 100644 (file)
@@ -1,22 +1,32 @@
+import {onChildEvent} from "../services/dom";
 
+/**
+ * Entity Selector
+ * @extends {Component}
+ */
 class EntitySelector {
 
-    constructor(elem) {
-        this.elem = elem;
+    setup() {
+        this.elem = this.$el;
+        this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
+        this.entityPermission = this.$opts.entityPermission || 'view';
+
+        this.input = this.$refs.input;
+        this.searchInput = this.$refs.search;
+        this.loading = this.$refs.loading;
+        this.resultsContainer = this.$refs.results;
+        this.addButton = this.$refs.add;
+
         this.search = '';
         this.lastClick = 0;
         this.selectedItemData = null;
 
-        const entityTypes = elem.hasAttribute('entity-types') ? elem.getAttribute('entity-types') : 'page,book,chapter';
-        const entityPermission = elem.hasAttribute('entity-permission') ? elem.getAttribute('entity-permission') : 'view';
-        this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}&permission=${encodeURIComponent(entityPermission)}`);
-
-        this.input = elem.querySelector('[entity-selector-input]');
-        this.searchInput = elem.querySelector('[entity-selector-search]');
-        this.loading = elem.querySelector('[entity-selector-loading]');
-        this.resultsContainer = elem.querySelector('[entity-selector-results]');
-        this.addButton = elem.querySelector('[entity-selector-add-button]');
+        this.setupListeners();
+        this.showLoading();
+        this.initialLoad();
+    }
 
+    setupListeners() {
         this.elem.addEventListener('click', this.onClick.bind(this));
 
         let lastSearch = 0;
@@ -42,8 +52,39 @@ class EntitySelector {
             });
         }
 
-        this.showLoading();
-        this.initialLoad();
+        // Keyboard navigation
+        onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
+            if (e.ctrlKey && e.code === 'Enter') {
+                const form = this.$el.closest('form');
+                if (form) {
+                    form.submit();
+                    e.preventDefault();
+                    return;
+                }
+            }
+
+            if (e.code === 'ArrowDown') {
+                this.focusAdjacent(true);
+            }
+            if (e.code === 'ArrowUp') {
+                this.focusAdjacent(false);
+            }
+        });
+
+        this.searchInput.addEventListener('keydown', e => {
+            if (e.code === 'ArrowDown') {
+                this.focusAdjacent(true);
+            }
+        })
+    }
+
+    focusAdjacent(forward = true) {
+        const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
+        const selectedIndex = items.indexOf(document.activeElement);
+        const newItem = items[selectedIndex+ (forward ? 1 : -1)] || items[0];
+        if (newItem) {
+            newItem.focus();
+        }
     }
 
     showLoading() {
@@ -57,15 +98,19 @@ class EntitySelector {
     }
 
     initialLoad() {
-        window.$http.get(this.searchUrl).then(resp => {
+        window.$http.get(this.searchUrl()).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
         })
     }
 
+    searchUrl() {
+        return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
+    }
+
     searchEntities(searchTerm) {
         this.input.value = '';
-        let url = `${this.searchUrl}&term=${encodeURIComponent(searchTerm)}`;
+        const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
         window.$http.get(url).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
@@ -73,8 +118,8 @@ class EntitySelector {
     }
 
     isDoubleClick() {
-        let now = Date.now();
-        let answer = now - this.lastClick < 300;
+        const now = Date.now();
+        const answer = now - this.lastClick < 300;
         this.lastClick = now;
         return answer;
     }
@@ -123,8 +168,8 @@ class EntitySelector {
     }
 
     unselectAll() {
-        let selected = this.elem.querySelectorAll('.selected');
-        for (let selectedElem of selected) {
+        const selected = this.elem.querySelectorAll('.selected');
+        for (const selectedElem of selected) {
             selectedElem.classList.remove('selected', 'primary-background');
         }
         this.selectedItemData = null;
diff --git a/resources/js/components/event-emit-select.js b/resources/js/components/event-emit-select.js
new file mode 100644 (file)
index 0000000..cf02158
--- /dev/null
@@ -0,0 +1,29 @@
+import {onSelect} from "../services/dom";
+
+/**
+ * EventEmitSelect
+ * Component will simply emit an event when selected.
+ *
+ * Has one required option: "name".
+ * A name of "hello" will emit a component DOM event of
+ * "event-emit-select-name"
+ *
+ * All options will be set as the "detail" of the event with
+ * their values included.
+ *
+ * @extends {Component}
+ */
+class EventEmitSelect {
+    setup() {
+        this.container = this.$el;
+        this.name = this.$opts.name;
+
+
+        onSelect(this.$el, () => {
+            this.$emit(this.name, this.$opts);
+        });
+    }
+
+}
+
+export default EventEmitSelect;
\ No newline at end of file
index eccd4b8f0a6b25e722b1754cd6c611db1ed3ac64..99737bfb8b0fb741564a5c854d566fd3f774067a 100644 (file)
@@ -1,31 +1,41 @@
 
 class HeaderMobileToggle {
 
-    constructor(elem) {
-        this.elem = elem;
-        this.toggleButton = elem.querySelector('.mobile-menu-toggle');
-        this.menu = elem.querySelector('.header-links');
-        this.open = false;
+    setup() {
+        this.elem = this.$el;
+        this.toggleButton = this.$refs.toggle;
+        this.menu = this.$refs.menu;
 
+        this.open = false;
         this.toggleButton.addEventListener('click', this.onToggle.bind(this));
         this.onWindowClick = this.onWindowClick.bind(this);
+        this.onKeyDown = this.onKeyDown.bind(this);
     }
 
     onToggle(event) {
         this.open = !this.open;
         this.menu.classList.toggle('show', this.open);
+        this.toggleButton.setAttribute('aria-expanded', this.open ? 'true' : 'false');
         if (this.open) {
+            this.elem.addEventListener('keydown', this.onKeyDown);
             window.addEventListener('click', this.onWindowClick)
         } else {
+            this.elem.removeEventListener('keydown', this.onKeyDown);
             window.removeEventListener('click', this.onWindowClick)
         }
         event.stopPropagation();
     }
 
+    onKeyDown(event) {
+        if (event.code === 'Escape') {
+            this.onToggle(event);
+        }
+    }
+
     onWindowClick(event) {
         this.onToggle(event);
     }
 
 }
 
-module.exports = HeaderMobileToggle;
\ No newline at end of file
+export default HeaderMobileToggle;
\ No newline at end of file
diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js
new file mode 100644 (file)
index 0000000..c974ab1
--- /dev/null
@@ -0,0 +1,208 @@
+import {onChildEvent, onSelect, removeLoading, showLoading} from "../services/dom";
+
+/**
+ * ImageManager
+ * @extends {Component}
+ */
+class ImageManager {
+
+    setup() {
+
+        // Options
+        this.uploadedTo = this.$opts.uploadedTo;
+
+        // Element References
+        this.container = this.$el;
+        this.popupEl = this.$refs.popup;
+        this.searchForm = this.$refs.searchForm;
+        this.searchInput = this.$refs.searchInput;
+        this.cancelSearch = this.$refs.cancelSearch;
+        this.listContainer = this.$refs.listContainer;
+        this.filterTabs = this.$manyRefs.filterTabs;
+        this.selectButton = this.$refs.selectButton;
+        this.formContainer = this.$refs.formContainer;
+        this.dropzoneContainer = this.$refs.dropzoneContainer;
+
+        // Instance data
+        this.type = 'gallery';
+        this.lastSelected = {};
+        this.lastSelectedTime = 0;
+        this.callback = null;
+        this.resetState = () => {
+            this.hasData = false;
+            this.page = 1;
+            this.filter = 'all';
+        };
+        this.resetState();
+
+        this.setupListeners();
+
+        window.ImageManager = this;
+    }
+
+    setupListeners() {
+        onSelect(this.filterTabs, e => {
+            this.resetAll();
+            this.filter = e.target.dataset.filter;
+            this.setActiveFilterTab(this.filter);
+            this.loadGallery();
+        });
+
+        this.searchForm.addEventListener('submit', event => {
+            this.resetListView();
+            this.loadGallery();
+            event.preventDefault();
+        });
+
+        onSelect(this.cancelSearch, event => {
+            this.resetListView();
+            this.resetSearchView();
+            this.loadGallery();
+            this.cancelSearch.classList.remove('active');
+        });
+
+        this.searchInput.addEventListener('input', event => {
+            this.cancelSearch.classList.toggle('active', this.searchInput.value.trim());
+        });
+
+        onChildEvent(this.listContainer, '.load-more', 'click', async event => {
+            showLoading(event.target);
+            this.page++;
+            await this.loadGallery();
+            event.target.remove();
+        });
+
+        this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
+
+        onSelect(this.selectButton, () => {
+            if (this.callback) {
+                this.callback(this.lastSelected);
+            }
+            this.hide();
+        });
+
+        onChildEvent(this.formContainer, '#image-manager-delete', 'click', event => {
+            if (this.lastSelected) {
+                this.loadImageEditForm(this.lastSelected.id, true);
+            }
+        });
+
+        this.formContainer.addEventListener('ajax-form-success', this.refreshGallery.bind(this));
+        this.container.addEventListener('dropzone-success', this.refreshGallery.bind(this));
+    }
+
+    show(callback, type = 'gallery') {
+        this.resetAll();
+
+        this.callback = callback;
+        this.type = type;
+        this.popupEl.components.popup.show();
+        this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery');
+
+        if (!this.hasData) {
+            this.loadGallery();
+            this.hasData = true;
+        }
+    }
+
+    hide() {
+        this.popupEl.components.popup.hide();
+    }
+
+    async loadGallery() {
+        const params = {
+            page: this.page,
+            search: this.searchInput.value || null,
+            uploaded_to: this.uploadedTo,
+            filter_type: this.filter === 'all' ? null : this.filter,
+        };
+
+        const {data: html} = await window.$http.get(`images/${this.type}`, params);
+        this.addReturnedHtmlElementsToList(html);
+        removeLoading(this.listContainer);
+    }
+
+    addReturnedHtmlElementsToList(html) {
+        const el = document.createElement('div');
+        el.innerHTML = html;
+        window.components.init(el);
+        for (const child of [...el.children]) {
+            this.listContainer.appendChild(child);
+        }
+    }
+
+    setActiveFilterTab(filterName) {
+        this.filterTabs.forEach(t => t.classList.remove('selected'));
+        const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName);
+        if (activeTab) {
+            activeTab.classList.add('selected');
+        }
+    }
+
+    resetAll() {
+        this.resetState();
+        this.resetListView();
+        this.resetSearchView();
+        this.resetEditForm();
+        this.setActiveFilterTab('all');
+        this.selectButton.classList.add('hidden');
+    }
+
+    resetSearchView() {
+        this.searchInput.value = '';
+    }
+
+    resetEditForm() {
+        this.formContainer.innerHTML = '';
+    }
+
+    resetListView() {
+        showLoading(this.listContainer);
+        this.page = 1;
+    }
+
+    refreshGallery() {
+        this.resetListView();
+        this.loadGallery();
+    }
+
+    onImageSelectEvent(event) {
+        const image = JSON.parse(event.detail.data);
+        const isDblClick = ((image && image.id === this.lastSelected.id)
+            && Date.now() - this.lastSelectedTime < 400);
+        const alreadySelected = event.target.classList.contains('selected');
+        [...this.listContainer.querySelectorAll('.selected')].forEach(el => {
+            el.classList.remove('selected');
+        });
+
+        if (!alreadySelected) {
+            event.target.classList.add('selected');
+            this.loadImageEditForm(image.id);
+        } else {
+            this.resetEditForm();
+        }
+        this.selectButton.classList.toggle('hidden', alreadySelected);
+
+        if (isDblClick && this.callback) {
+            this.callback(image);
+            this.hide();
+        }
+
+        this.lastSelected = image;
+        this.lastSelectedTime = Date.now();
+    }
+
+    async loadImageEditForm(imageId, requestDelete = false) {
+        if (!requestDelete) {
+            this.formContainer.innerHTML = '';
+        }
+
+        const params = requestDelete ? {delete: true} : {};
+        const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
+        this.formContainer.innerHTML = formHtml;
+        window.components.init(this.formContainer);
+    }
+
+}
+
+export default ImageManager;
\ No newline at end of file
index 68f97b2800d7b11fba5910b227d82ecc34774a89..010ee04bae01f349a95ccf9d0eb8dac3f86ce051 100644 (file)
-const componentMapping = {};
+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"
+import chapterToggle from "./chapter-toggle.js"
+import codeEditor from "./code-editor.js"
+import codeHighlighter from "./code-highlighter.js"
+import collapsible from "./collapsible.js"
+import customCheckbox from "./custom-checkbox.js"
+import detailsHighlighter from "./details-highlighter.js"
+import dropdown from "./dropdown.js"
+import dropdownSearch from "./dropdown-search.js"
+import dropzone from "./dropzone.js"
+import editorToolbox from "./editor-toolbox.js"
+import entityPermissionsEditor from "./entity-permissions-editor.js"
+import entitySearch from "./entity-search.js"
+import entitySelector from "./entity-selector.js"
+import entitySelectorPopup from "./entity-selector-popup.js"
+import eventEmitSelect from "./event-emit-select.js"
+import expandToggle from "./expand-toggle.js"
+import headerMobileToggle from "./header-mobile-toggle.js"
+import homepageControl from "./homepage-control.js"
+import imageManager from "./image-manager.js"
+import imagePicker from "./image-picker.js"
+import index from "./index.js"
+import listSortControl from "./list-sort-control.js"
+import markdownEditor from "./markdown-editor.js"
+import newUserPassword from "./new-user-password.js"
+import notification from "./notification.js"
+import optionalInput from "./optional-input.js"
+import pageComments from "./page-comments.js"
+import pageDisplay from "./page-display.js"
+import pageEditor from "./page-editor.js"
+import pagePicker from "./page-picker.js"
+import permissionsTable from "./permissions-table.js"
+import popup from "./popup.js"
+import settingAppColorPicker from "./setting-app-color-picker.js"
+import settingColorPicker from "./setting-color-picker.js"
+import shelfSort from "./shelf-sort.js"
+import sidebar from "./sidebar.js"
+import sortableList from "./sortable-list.js"
+import submitOnChange from "./submit-on-change.js"
+import tabs from "./tabs.js"
+import tagManager from "./tag-manager.js"
+import templateManager from "./template-manager.js"
+import toggleSwitch from "./toggle-switch.js"
+import triLayout from "./tri-layout.js"
+import userSelect from "./user-select.js"
+import wysiwygEditor from "./wysiwyg-editor.js"
 
-const definitionFiles = require.context('./', false, /\.js$/);
-for (const fileName of definitionFiles.keys()) {
-    const name = fileName.replace('./', '').split('.')[0];
-    if (name !== 'index') {
-        componentMapping[name] = definitionFiles(fileName).default;
-    }
-}
+const componentMapping = {
+    "add-remove-rows": addRemoveRows,
+    "ajax-delete-row": ajaxDeleteRow,
+    "ajax-form": ajaxForm,
+    "attachments": attachments,
+    "attachments-list": attachmentsList,
+    "auto-suggest": autoSuggest,
+    "back-to-top": backToTop,
+    "book-sort": bookSort,
+    "chapter-toggle": chapterToggle,
+    "code-editor": codeEditor,
+    "code-highlighter": codeHighlighter,
+    "collapsible": collapsible,
+    "custom-checkbox": customCheckbox,
+    "details-highlighter": detailsHighlighter,
+    "dropdown": dropdown,
+    "dropdown-search": dropdownSearch,
+    "dropzone": dropzone,
+    "editor-toolbox": editorToolbox,
+    "entity-permissions-editor": entityPermissionsEditor,
+    "entity-search": entitySearch,
+    "entity-selector": entitySelector,
+    "entity-selector-popup": entitySelectorPopup,
+    "event-emit-select": eventEmitSelect,
+    "expand-toggle": expandToggle,
+    "header-mobile-toggle": headerMobileToggle,
+    "homepage-control": homepageControl,
+    "image-manager": imageManager,
+    "image-picker": imagePicker,
+    "index": index,
+    "list-sort-control": listSortControl,
+    "markdown-editor": markdownEditor,
+    "new-user-password": newUserPassword,
+    "notification": notification,
+    "optional-input": optionalInput,
+    "page-comments": pageComments,
+    "page-display": pageDisplay,
+    "page-editor": pageEditor,
+    "page-picker": pagePicker,
+    "permissions-table": permissionsTable,
+    "popup": popup,
+    "setting-app-color-picker": settingAppColorPicker,
+    "setting-color-picker": settingColorPicker,
+    "shelf-sort": shelfSort,
+    "sidebar": sidebar,
+    "sortable-list": sortableList,
+    "submit-on-change": submitOnChange,
+    "tabs": tabs,
+    "tag-manager": tagManager,
+    "template-manager": templateManager,
+    "toggle-switch": toggleSwitch,
+    "tri-layout": triLayout,
+    "user-select": userSelect,
+    "wysiwyg-editor": wysiwygEditor,
+};
 
 window.components = {};
 
@@ -40,6 +140,14 @@ function initComponent(name, element) {
         instance.$refs = allRefs.refs;
         instance.$manyRefs = allRefs.manyRefs;
         instance.$opts = parseOpts(name, element);
+        instance.$emit = (eventName, data = {}) => {
+            data.from = instance;
+            const event = new CustomEvent(`${name}-${eventName}`, {
+                bubbles: true,
+                detail: data
+            });
+            instance.$el.dispatchEvent(event);
+        };
         if (typeof instance.setup === 'function') {
             instance.setup();
         }
@@ -121,7 +229,7 @@ function parseOpts(name, element) {
 function kebabToCamel(kebab) {
     const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
     const words = kebab.split('-');
-    return words[0] + words.slice(1).map(ucFirst).join();
+    return words[0] + words.slice(1).map(ucFirst).join('');
 }
 
 /**
@@ -158,4 +266,5 @@ export default initAll;
  * @property {Object<String, HTMLElement>} $refs
  * @property {Object<String, HTMLElement[]>} $manyRefs
  * @property {Object<String, String>} $opts
+ * @property {function(string, Object)} $emit
  */
\ No newline at end of file
index cc9a7b859ddb9eeb6f540d1b1c545dd8f1820987..a90f74e2746401562f59c03cea0b8824298f9dc8 100644 (file)
@@ -8,12 +8,13 @@ import DrawIO from "../services/drawio";
 
 class MarkdownEditor {
 
-    constructor(elem) {
-        this.elem = elem;
+    setup() {
+        this.elem = this.$el;
 
-        const pageEditor = document.getElementById('page-editor');
-        this.pageId = pageEditor.getAttribute('page-id');
-        this.textDirection = pageEditor.getAttribute('text-direction');
+        this.pageId = this.$opts.pageId;
+        this.textDirection = this.$opts.textDirection;
+        this.imageUploadErrorText = this.$opts.imageUploadErrorText;
+        this.serverUploadLimitText = this.$opts.serverUploadLimitText;
 
         this.markdown = new MarkdownIt({html: true});
         this.markdown.use(mdTasksLists, {label: true});
@@ -22,17 +23,22 @@ class MarkdownEditor {
 
         this.displayStylesLoaded = false;
         this.input = this.elem.querySelector('textarea');
-        this.htmlInput = this.elem.querySelector('input[name=html]');
         this.cm = code.markdownEditor(this.input);
 
         this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
 
-        this.display.addEventListener('load', () => {
+        const displayLoad = () => {
             this.displayDoc = this.display.contentDocument;
             this.init();
-        });
+        };
+
+        if (this.display.contentDocument.readyState === 'complete') {
+            displayLoad();
+        } else {
+            this.display.addEventListener('load', displayLoad.bind(this));
+        }
 
-        window.$events.emitPublic(elem, 'editor-markdown::setup', {
+        window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
             markdownIt: this.markdown,
             displayEl: this.display,
             codeMirrorInstance: this.cm,
@@ -119,7 +125,6 @@ class MarkdownEditor {
         // Set body content
         this.displayDoc.body.className = 'page-content';
         this.displayDoc.body.innerHTML = html;
-        this.htmlInput.value = html;
 
         // Copy styles from page head and set custom styles for editor
         this.loadStylesIntoDisplay();
@@ -251,7 +256,7 @@ class MarkdownEditor {
             }
 
             const clipboard = new Clipboard(event.dataTransfer);
-            if (clipboard.hasItems()) {
+            if (clipboard.hasItems() && clipboard.getImages().length > 0) {
                 const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
                 cm.setCursor(cursorPos);
                 event.stopPropagation();
@@ -368,7 +373,7 @@ class MarkdownEditor {
                 const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`;
                 replaceContent(placeHolderText, newContent);
             }).catch(err => {
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', context.imageUploadErrorText);
                 replaceContent(placeHolderText, selectedText);
                 console.log(err);
             });
@@ -435,15 +440,14 @@ class MarkdownEditor {
 
             const data = {
                 image: pngData,
-                uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
+                uploaded_to: Number(this.pageId),
             };
 
-            window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
+            window.$http.post("/images/drawio", data).then(resp => {
                 this.insertDrawing(resp.data, cursorPos);
                 DrawIO.close();
             }).catch(err => {
-                window.$events.emit('error', trans('errors.image_upload_error'));
-                console.log(err);
+                this.handleDrawingUploadError(err);
             });
         });
     }
@@ -471,10 +475,10 @@ class MarkdownEditor {
 
             let data = {
                 image: pngData,
-                uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
+                uploaded_to: Number(this.pageId),
             };
 
-            window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
+            window.$http.post("/images/drawio", data).then(resp => {
                 let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
                 let newContent = this.cm.getValue().split('\n').map(line => {
                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
@@ -487,12 +491,20 @@ class MarkdownEditor {
                 this.cm.focus();
                 DrawIO.close();
             }).catch(err => {
-                window.$events.emit('error', trans('errors.image_upload_error'));
-                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');
@@ -558,6 +570,12 @@ class MarkdownEditor {
             this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
         });
 
+        // Insert editor content at the current location
+        window.$events.listen('editor::insert', (eventContent) => {
+            const markdown = getContentToInsert(eventContent);
+            this.cm.replaceSelection(markdown);
+        });
+
         // Focus on editor
         window.$events.listen('editor::focus', () => {
             this.cm.focus();
index 5d826cba13d1bb12c0e95e4ba2020db66d487985..c86eead1b865bd8bdaa8184f44bd4ab55a961d7b 100644 (file)
@@ -1,16 +1,31 @@
 import {scrollAndHighlightElement} from "../services/util";
 
+/**
+ * @extends {Component}
+ */
 class PageComments {
 
-    constructor(elem) {
-        this.elem = elem;
-        this.pageId = Number(elem.getAttribute('page-id'));
+    setup() {
+        this.elem = this.$el;
+        this.pageId = Number(this.$opts.pageId);
+
+        // Element references
+        this.container = this.$refs.commentContainer;
+        this.formContainer = this.$refs.formContainer;
+        this.commentCountBar = this.$refs.commentCountBar;
+        this.addButtonContainer = this.$refs.addButtonContainer;
+        this.replyToRow = this.$refs.replyToRow;
+
+        // Translations
+        this.updatedText = this.$opts.updatedText;
+        this.deletedText = this.$opts.deletedText;
+        this.createdText = this.$opts.createdText;
+        this.countText = this.$opts.countText;
+
+        // Internal State
         this.editingComment = null;
         this.parentId = null;
 
-        this.container = elem.querySelector('[comment-container]');
-        this.formContainer = elem.querySelector('[comment-form-container]');
-
         if (this.formContainer) {
             this.form = this.formContainer.querySelector('form');
             this.formInput = this.form.querySelector('textarea');
@@ -32,13 +47,14 @@ class PageComments {
         if (actionElem === null) return;
         event.preventDefault();
 
-        let action = actionElem.getAttribute('action');
-        if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
+        const action = actionElem.getAttribute('action');
+        const comment = actionElem.closest('[comment]');
+        if (action === 'edit') this.editComment(comment);
         if (action === 'closeUpdateForm') this.closeUpdateForm();
-        if (action === 'delete') this.deleteComment(actionElem.closest('[comment]'));
+        if (action === 'delete') this.deleteComment(comment);
         if (action === 'addComment') this.showForm();
         if (action === 'hideForm') this.hideForm();
-        if (action === 'reply') this.setReply(actionElem.closest('[comment]'));
+        if (action === 'reply') this.setReply(comment);
         if (action === 'remove-reply-to') this.removeReplyTo();
     }
 
@@ -69,14 +85,15 @@ class PageComments {
         };
         this.showLoading(form);
         let commentId = this.editingComment.getAttribute('comment');
-        window.$http.put(`/ajax/comment/${commentId}`, reqData).then(resp => {
+        window.$http.put(`/comment/${commentId}`, reqData).then(resp => {
             let newComment = document.createElement('div');
             newComment.innerHTML = resp.data;
             this.editingComment.innerHTML = newComment.children[0].innerHTML;
-            window.$events.emit('success', window.trans('entities.comment_updated_success'));
+            window.$events.success(this.updatedText);
             window.components.init(this.editingComment);
             this.closeUpdateForm();
             this.editingComment = null;
+        }).catch(window.$events.showValidationErrors).then(() => {
             this.hideLoading(form);
         });
     }
@@ -84,9 +101,9 @@ class PageComments {
     deleteComment(commentElem) {
         let id = commentElem.getAttribute('comment');
         this.showLoading(commentElem.querySelector('[comment-content]'));
-        window.$http.delete(`/ajax/comment/${id}`).then(resp => {
+        window.$http.delete(`/comment/${id}`).then(resp => {
             commentElem.parentNode.removeChild(commentElem);
-            window.$events.emit('success', window.trans('entities.comment_deleted_success'));
+            window.$events.success(this.deletedText);
             this.updateCount();
             this.hideForm();
         });
@@ -101,21 +118,24 @@ class PageComments {
             parent_id: this.parentId || null,
         };
         this.showLoading(this.form);
-        window.$http.post(`/ajax/page/${this.pageId}/comment`, reqData).then(resp => {
+        window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
             let newComment = document.createElement('div');
             newComment.innerHTML = resp.data;
             let newElem = newComment.children[0];
             this.container.appendChild(newElem);
             window.components.init(newElem);
-            window.$events.emit('success', window.trans('entities.comment_created_success'));
+            window.$events.success(this.createdText);
             this.resetForm();
             this.updateCount();
+        }).catch(err => {
+            window.$events.showValidationErrors(err);
+            this.hideLoading(this.form);
         });
     }
 
     updateCount() {
         let count = this.container.children.length;
-        this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count});
+        this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count});
     }
 
     resetForm() {
@@ -129,7 +149,7 @@ class PageComments {
     showForm() {
         this.formContainer.style.display = 'block';
         this.formContainer.parentNode.style.display = 'block';
-        this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
+        this.addButtonContainer.style.display = 'none';
         this.formInput.focus();
         this.formInput.scrollIntoView({behavior: "smooth"});
     }
@@ -137,14 +157,12 @@ class PageComments {
     hideForm() {
         this.formContainer.style.display = 'none';
         this.formContainer.parentNode.style.display = 'none';
-        const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
         if (this.getCommentCount() > 0) {
-            this.elem.appendChild(addButtonContainer)
+            this.elem.appendChild(this.addButtonContainer)
         } else {
-            const countBar = this.elem.querySelector('[comment-count-bar]');
-            countBar.appendChild(addButtonContainer);
+            this.commentCountBar.appendChild(this.addButtonContainer);
         }
-        addButtonContainer.style.display = 'block';
+        this.addButtonContainer.style.display = 'block';
     }
 
     getCommentCount() {
@@ -154,15 +172,15 @@ class PageComments {
     setReply(commentElem) {
         this.showForm();
         this.parentId = Number(commentElem.getAttribute('local-id'));
-        this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
-        let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
+        this.replyToRow.style.display = 'block';
+        const replyLink = this.replyToRow.querySelector('a');
         replyLink.textContent = `#${this.parentId}`;
         replyLink.href = `#comment${this.parentId}`;
     }
 
     removeReplyTo() {
         this.parentId = null;
-        this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
+        this.replyToRow.style.display = 'none';
     }
 
     showLoading(formElem) {
index 2be1c1c48b8cc93f5ac147b64023cf910eabc3fa..cc55fe35e1e38caae9de9f9cca013e0c7b1d14f6 100644 (file)
@@ -12,6 +12,7 @@ class PageDisplay {
         Code.highlight();
         this.setupPointer();
         this.setupNavHighlighting();
+        this.setupDetailsCodeBlockRefresh();
 
         // Check the hash on load
         if (window.location.hash) {
@@ -196,6 +197,16 @@ class PageDisplay {
             });
         }
     }
+
+    setupDetailsCodeBlockRefresh() {
+        const onToggle = event => {
+            const codeMirrors = [...event.target.querySelectorAll('.CodeMirror')];
+            codeMirrors.forEach(cm => cm.CodeMirror && cm.CodeMirror.refresh());
+        };
+
+        const details = [...this.elem.querySelectorAll('details')];
+        details.forEach(detail => detail.addEventListener('toggle', onToggle));
+    }
 }
 
 export default PageDisplay;
diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js
new file mode 100644 (file)
index 0000000..5f35e64
--- /dev/null
@@ -0,0 +1,188 @@
+import * as Dates from "../services/dates";
+import {onSelect} from "../services/dom";
+
+/**
+ * Page Editor
+ * @extends {Component}
+ */
+class PageEditor {
+    setup() {
+        // Options
+        this.draftsEnabled = this.$opts.draftsEnabled === 'true';
+        this.editorType = this.$opts.editorType;
+        this.pageId = Number(this.$opts.pageId);
+        this.isNewDraft = this.$opts.pageNewDraft === 'true';
+        this.hasDefaultTitle = this.$opts.hasDefaultTitle || false;
+
+        // Elements
+        this.container = this.$el;
+        this.titleElem = this.$refs.titleContainer.querySelector('input');
+        this.saveDraftButton = this.$refs.saveDraft;
+        this.discardDraftButton = this.$refs.discardDraft;
+        this.discardDraftWrap = this.$refs.discardDraftWrap;
+        this.draftDisplay = this.$refs.draftDisplay;
+        this.draftDisplayIcon = this.$refs.draftDisplayIcon;
+        this.changelogInput = this.$refs.changelogInput;
+        this.changelogDisplay = this.$refs.changelogDisplay;
+
+        // Translations
+        this.draftText = this.$opts.draftText;
+        this.autosaveFailText = this.$opts.autosaveFailText;
+        this.editingPageText = this.$opts.editingPageText;
+        this.draftDiscardedText = this.$opts.draftDiscardedText;
+        this.setChangelogText = this.$opts.setChangelogText;
+
+        // State data
+        this.editorHTML = '';
+        this.editorMarkdown = '';
+        this.autoSave = {
+            interval: null,
+            frequency: 30000,
+            last: 0,
+        };
+        this.shownWarningsCache = new Set();
+
+        if (this.pageId !== 0 && this.draftsEnabled) {
+            window.setTimeout(() => {
+                this.startAutoSave();
+            }, 1000);
+        }
+        this.draftDisplay.innerHTML = this.draftText;
+
+        this.setupListeners();
+        this.setInitialFocus();
+    }
+
+    setupListeners() {
+        // Listen to save events from editor
+        window.$events.listen('editor-save-draft', this.saveDraft.bind(this));
+        window.$events.listen('editor-save-page', this.savePage.bind(this));
+
+        // Listen to content changes from the editor
+        window.$events.listen('editor-html-change', html => {
+            this.editorHTML = html;
+        });
+        window.$events.listen('editor-markdown-change', markdown => {
+            this.editorMarkdown = markdown;
+        });
+
+        // Changelog controls
+        this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
+
+        // Draft Controls
+        onSelect(this.saveDraftButton, this.saveDraft.bind(this));
+        onSelect(this.discardDraftButton, this.discardDraft.bind(this));
+    }
+
+    setInitialFocus() {
+        if (this.hasDefaultTitle) {
+            return this.titleElem.select();
+        }
+
+        window.setTimeout(() => {
+            window.$events.emit('editor::focus', '');
+        }, 500);
+    }
+
+    startAutoSave() {
+        let lastContent = this.titleElem.value.trim() + '::' + this.editorHTML;
+        this.autoSaveInterval = window.setInterval(() => {
+            // Stop if manually saved recently to prevent bombarding the server
+            let savedRecently = (Date.now() - this.autoSave.last < (this.autoSave.frequency)/2);
+            if (savedRecently) return;
+            const newContent = this.titleElem.value.trim() + '::' + this.editorHTML;
+            if (newContent !== lastContent) {
+                lastContent = newContent;
+                this.saveDraft();
+            }
+
+        }, this.autoSave.frequency);
+    }
+
+    savePage() {
+        this.container.closest('form').submit();
+    }
+
+    async saveDraft() {
+        const data = {
+            name: this.titleElem.value.trim(),
+            html: this.editorHTML,
+        };
+
+        if (this.editorType === 'markdown') {
+            data.markdown = this.editorMarkdown;
+        }
+
+        try {
+            const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
+            if (!this.isNewDraft) {
+                this.toggleDiscardDraftVisibility(true);
+            }
+            this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
+            this.autoSave.last = Date.now();
+            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 {
+                const saveKey = `draft-save-fail-${(new Date()).toISOString()}`;
+                window.localStorage.setItem(saveKey, JSON.stringify(data));
+            } catch (err) {}
+
+            window.$events.emit('error', this.autosaveFailText);
+        }
+
+    }
+
+    draftNotifyChange(text) {
+        this.draftDisplay.innerText = text;
+        this.draftDisplayIcon.classList.add('visible');
+        window.setTimeout(() => {
+            this.draftDisplayIcon.classList.remove('visible');
+        }, 2000);
+    }
+
+    async discardDraft() {
+        let response;
+        try {
+            response = await window.$http.get(`/ajax/page/${this.pageId}`);
+        } catch (e) {
+            return console.error(e);
+        }
+
+        if (this.autoSave.interval) {
+            window.clearInterval(this.autoSave.interval);
+        }
+
+        this.draftDisplay.innerText = this.editingPageText;
+        this.toggleDiscardDraftVisibility(false);
+        window.$events.emit('editor-html-update', response.data.html || '');
+        window.$events.emit('editor-markdown-update', response.data.markdown || response.data.html);
+
+        this.titleElem.value = response.data.name;
+        window.setTimeout(() => {
+            this.startAutoSave();
+        }, 1000);
+        window.$events.emit('success', this.draftDiscardedText);
+
+    }
+
+    updateChangelogDisplay() {
+        let summary = this.changelogInput.value.trim();
+        if (summary.length === 0) {
+            summary = this.setChangelogText;
+        } else if (summary.length > 16) {
+            summary = summary.slice(0, 16) + '...';
+        }
+        this.changelogDisplay.innerText = summary;
+    }
+
+    toggleDiscardDraftVisibility(show) {
+        this.discardDraftWrap.classList.toggle('hidden', !show);
+    }
+
+}
+
+export default PageEditor;
\ No newline at end of file
index 6efcb4e84f4841a40b184bbed2aceb01a5f40840..0af0e11c901a5b58d98f4ef7687004c117334e3d 100644 (file)
@@ -2,6 +2,11 @@ import Sortable from "sortablejs";
 
 /**
  * SortableList
+ *
+ * Can have data set on the dragged items by setting a 'data-drag-content' attribute.
+ * This attribute must contain JSON where the keys are content types and the values are
+ * the data to set on the data-transfer.
+ *
  * @extends {Component}
  */
 class SortableList {
@@ -9,9 +14,24 @@ class SortableList {
         this.container = this.$el;
         this.handleSelector = this.$opts.handleSelector;
 
-        new Sortable(this.container, {
+        const sortable = new Sortable(this.container, {
             handle: this.handleSelector,
             animation: 150,
+            onSort: () => {
+                this.$emit('sort', {ids: sortable.toArray()});
+            },
+            setData(dataTransferItem, dragEl) {
+                const jsonContent = dragEl.getAttribute('data-drag-content');
+                if (jsonContent) {
+                    const contentByType = JSON.parse(jsonContent);
+                    for (const [type, content] of Object.entries(contentByType)) {
+                        dataTransferItem.setData(type, content);
+                    }
+                }
+            },
+            revertOnSpill: true,
+            dropBubble: true,
+            dragoverBubble: false,
         });
     }
 }
diff --git a/resources/js/components/submit-on-change.js b/resources/js/components/submit-on-change.js
new file mode 100644 (file)
index 0000000..aeacae2
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Submit on change
+ * Simply submits a parent form when this input is changed.
+ * @extends {Component}
+ */
+class SubmitOnChange {
+
+    setup() {
+        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();
+            }
+        });
+    }
+
+}
+
+export default SubmitOnChange;
\ No newline at end of file
diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.js
new file mode 100644 (file)
index 0000000..7121d70
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Tabs
+ * Works by matching 'tabToggle<Key>' with 'tabContent<Key>' sections.
+ * @extends {Component}
+ */
+import {onSelect} from "../services/dom";
+
+class Tabs {
+
+    setup() {
+        this.tabContentsByName = {};
+        this.tabButtonsByName = {};
+        this.allContents = [];
+        this.allButtons = [];
+
+        for (const [key, elems] of Object.entries(this.$manyRefs || {})) {
+            if (key.startsWith('toggle')) {
+                const cleanKey = key.replace('toggle', '').toLowerCase();
+                onSelect(elems, e => this.show(cleanKey));
+                this.allButtons.push(...elems);
+                this.tabButtonsByName[cleanKey] = elems;
+            }
+            if (key.startsWith('content')) {
+                const cleanKey = key.replace('content', '').toLowerCase();
+                this.tabContentsByName[cleanKey] = elems;
+                this.allContents.push(...elems);
+            }
+        }
+    }
+
+    show(key) {
+        this.allContents.forEach(c => {
+            c.classList.add('hidden');
+            c.classList.remove('selected');
+        });
+        this.allButtons.forEach(b => b.classList.remove('selected'));
+
+        const contents = this.tabContentsByName[key] || [];
+        const buttons = this.tabButtonsByName[key] || [];
+        if (contents.length > 0) {
+            contents.forEach(c => {
+                c.classList.remove('hidden')
+                c.classList.add('selected')
+            });
+            buttons.forEach(b => b.classList.add('selected'));
+        }
+    }
+
+}
+
+export default Tabs;
\ No newline at end of file
index 905ca03b1020d566859366d6e2ecc8e851edc784..f801e52a193715fdea427fa4eac03c2372ca9abf 100644 (file)
@@ -1,8 +1,9 @@
 
 class TriLayout {
 
-    constructor(elem) {
-        this.elem = elem;
+    setup() {
+        this.container = this.$refs.container;
+        this.tabs = this.$manyRefs.tab;
 
         this.lastLayoutType = 'none';
         this.onDestroy = null;
@@ -43,13 +44,12 @@ class TriLayout {
     }
 
     setupMobile() {
-        const layoutTabs = document.querySelectorAll('[tri-layout-mobile-tab]');
-        for (let tab of layoutTabs) {
+        for (const tab of this.tabs) {
             tab.addEventListener('click', this.mobileTabClick);
         }
 
         this.onDestroy = () => {
-            for (let tab of layoutTabs) {
+            for (const tab of this.tabs) {
                 tab.removeEventListener('click', this.mobileTabClick);
             }
         }
@@ -65,7 +65,7 @@ class TriLayout {
      * @param event
      */
     mobileTabClick(event) {
-        const tab = event.target.getAttribute('tri-layout-mobile-tab');
+        const tab = event.target.dataset.tab;
         this.showTab(tab);
     }
 
@@ -79,21 +79,21 @@ class TriLayout {
 
     /**
      * Show the given tab
-     * @param tabName
+     * @param {String} tabName
+     * @param {Boolean }scroll
      */
     showTab(tabName, scroll = true) {
         this.scrollCache[this.lastTabShown] = document.documentElement.scrollTop;
 
         // Set tab status
-        const tabs = document.querySelectorAll('.tri-layout-mobile-tab');
-        for (let tab of tabs) {
-            const isActive = (tab.getAttribute('tri-layout-mobile-tab') === tabName);
-            tab.classList.toggle('active', isActive);
+        for (const tab of this.tabs) {
+            const isActive = (tab.dataset.tab === tabName);
+            tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
         }
 
         // Toggle section
         const showInfo = (tabName === 'info');
-        this.elem.classList.toggle('show-info', showInfo);
+        this.container.classList.toggle('show-info', showInfo);
 
         // Set the scroll position from cache
         if (scroll) {
diff --git a/resources/js/components/user-select.js b/resources/js/components/user-select.js
new file mode 100644 (file)
index 0000000..aba43e0
--- /dev/null
@@ -0,0 +1,25 @@
+import {onChildEvent} from "../services/dom";
+
+class UserSelect {
+
+    setup() {
+        this.input = this.$refs.input;
+        this.userInfoContainer = this.$refs.userInfo;
+
+        this.hide = this.$el.components.dropdown.hide;
+
+        onChildEvent(this.$el, 'a.dropdown-search-item', 'click', this.selectUser.bind(this));
+    }
+
+    selectUser(event, userEl) {
+        event.preventDefault();
+        const id = userEl.getAttribute('data-id');
+        this.input.value = id;
+        this.userInfoContainer.innerHTML = userEl.innerHTML;
+        this.input.dispatchEvent(new Event('change', {bubbles: true}));
+        this.hide();
+    }
+
+}
+
+export default UserSelect;
\ No newline at end of file
index 5956b5e7a421145f52bbb98672f6aa4ce4e438f3..bde73f4bfb12aa2e87e4f3d250e7b76d8831e314 100644 (file)
@@ -38,7 +38,7 @@ function editorPaste(event, editor, wysiwygComponent) {
                 editor.dom.replace(newEl, id);
             }).catch(err => {
                 editor.dom.remove(id);
-                window.$events.emit('error', trans('errors.image_upload_error'));
+                window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
                 console.log(err);
             });
         }, 10);
@@ -152,8 +152,8 @@ function codePlugin() {
             return;
         }
 
-        let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
-        let currentCode = selectedNode.querySelector('textarea').textContent;
+        const lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : '';
+        const currentCode = selectedNode.querySelector('textarea').textContent;
 
         window.components.first('code-editor').open(currentCode, lang, (code, lang) => {
             const editorElem = selectedNode.querySelector('.CodeMirror');
@@ -212,7 +212,7 @@ function codePlugin() {
             showPopup(editor);
         });
 
-        editor.on('SetContent', function () {
+        function parseCodeMirrorInstances() {
 
             // Recover broken codemirror instances
             $('.CodeMirrorContainer').filter((index ,elem) => {
@@ -225,18 +225,30 @@ function codePlugin() {
                 return elem.contentEditable !== "false";
             });
 
-            if (!codeSamples.length) return;
+            codeSamples.each((index, elem) => {
+                Code.wysiwygView(elem);
+            });
+        }
+
+        editor.on('init', function() {
+            // Parse code mirror instances on init, but delay a little so this runs after
+            // initial styles are fetched into the editor.
             editor.undoManager.transact(function () {
-                codeSamples.each((index, elem) => {
-                    Code.wysiwygView(elem);
-                });
+                parseCodeMirrorInstances();
             });
+            // Parsed code mirror blocks when content is set but wait before setting this handler
+            // to avoid any init 'SetContent' events.
+            setTimeout(() => {
+                editor.on('SetContent', () => {
+                    setTimeout(parseCodeMirrorInstances, 100);
+                });
+            }, 200);
         });
 
     });
 }
 
-function drawIoPlugin(drawioUrl, isDarkMode) {
+function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) {
 
     let pageEditor = null;
     let currentNode = null;
@@ -270,7 +282,15 @@ function drawIoPlugin(drawioUrl, isDarkMode) {
     async function updateContent(pngData) {
         const id = "image-" + Math.random().toString(16).slice(2);
         const loadingImage = window.baseUrl('/loading.gif');
-        const pageId = Number(document.getElementById('page-editor').getAttribute('page-id'));
+
+        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) {
@@ -281,8 +301,7 @@ function drawIoPlugin(drawioUrl, isDarkMode) {
                 pageEditor.dom.setAttrib(imgElem, 'src', img.url);
                 pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
             } catch (err) {
-                window.$events.emit('error', trans('errors.image_upload_error'));
-                console.log(err);
+                handleUploadError(err);
             }
             return;
         }
@@ -296,8 +315,7 @@ function drawIoPlugin(drawioUrl, isDarkMode) {
                 pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
             } catch (err) {
                 pageEditor.dom.remove(id);
-                window.$events.emit('error', trans('errors.image_upload_error'));
-                console.log(err);
+                handleUploadError(err);
             }
         }, 5);
     }
@@ -402,6 +420,11 @@ function listenForBookStackEditorEvents(editor) {
         editor.setContent(content);
     });
 
+    // Insert editor content at the current location
+    window.$events.listen('editor::insert', ({html}) => {
+        editor.insertContent(html);
+    });
+
     // Focus on the editor
     window.$events.listen('editor::focus', () => {
         editor.focus();
@@ -410,19 +433,20 @@ function listenForBookStackEditorEvents(editor) {
 
 class WysiwygEditor {
 
-    constructor(elem) {
-        this.elem = elem;
+    setup() {
+        this.elem = this.$el;
 
-        const pageEditor = document.getElementById('page-editor');
-        this.pageId = pageEditor.getAttribute('page-id');
-        this.textDirection = pageEditor.getAttribute('text-direction');
+        this.pageId = this.$opts.pageId;
+        this.textDirection = this.$opts.textDirection;
+        this.imageUploadErrorText = this.$opts.imageUploadErrorText;
+        this.serverUploadLimitText = this.$opts.serverUploadLimitText;
         this.isDarkMode = document.documentElement.classList.contains('dark-mode');
 
-        this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media";
+        this.plugins = "image imagetools table textcolor paste link autolink fullscreen code customhr autosave lists codeeditor media";
         this.loadPlugins();
 
         this.tinyMceConfig = this.getTinyMceConfig();
-        window.$events.emitPublic(elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
+        window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
         window.tinymce.init(this.tinyMceConfig);
     }
 
@@ -433,7 +457,7 @@ class WysiwygEditor {
         const drawioUrlElem = document.querySelector('[drawio-url]');
         if (drawioUrlElem) {
             const url = drawioUrlElem.getAttribute('drawio-url');
-            drawIoPlugin(url, this.isDarkMode);
+            drawIoPlugin(url, this.isDarkMode, this.pageId, this);
             this.plugins += ' drawio';
         }
 
@@ -639,6 +663,7 @@ class WysiwygEditor {
 
                 });
 
+                // Custom drop event handling
                 editor.on('drop', function (event) {
                     let dom = editor.dom,
                         rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
index e0c7b34e5a85d5c22e57236c04346c95b7041a38..ffdb54e191e1557b7c3cbb6e9f6b4402913c40b9 100644 (file)
@@ -7,11 +7,10 @@ window.baseUrl = function(path) {
 };
 
 // Set events and http services on window
-import Events from "./services/events"
+import events from "./services/events"
 import httpInstance from "./services/http"
-const eventManager = new Events();
 window.$http = httpInstance;
-window.$events = eventManager;
+window.$events = events;
 
 // Translation setup
 // Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
@@ -19,14 +18,8 @@ import Translations from "./services/translations"
 const translator = new Translations();
 window.trans = translator.get.bind(translator);
 window.trans_choice = translator.getPlural.bind(translator);
+window.trans_plural = translator.parsePlural.bind(translator);
 
-// Make services available to Vue instances
-import Vue from "vue"
-Vue.prototype.$http = httpInstance;
-Vue.prototype.$events = eventManager;
-
-// Load Vues and components
-import vues from "./vues/vues"
+// Load Components
 import components from "./components"
-vues();
 components();
\ No newline at end of file
index a7dfa587f203dcea41b2c781c36887e0d6b28d82..361ccc3f18224108283ad7d67772817c46bb2a26 100644 (file)
@@ -26,6 +26,8 @@ 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';
 
@@ -84,6 +86,10 @@ const modeMap = {
     bash: 'shell',
     toml: 'toml',
     sql: 'text/x-sql',
+    vbs: 'vbscript',
+    vbscript: 'vbscript',
+    'vb.net': 'text/x-vb',
+    vbnet: 'text/x-vb',
     xml: 'xml',
     yaml: 'yaml',
     yml: 'yaml',
@@ -235,9 +241,7 @@ function wysiwygView(elem) {
         theme: getTheme(),
         readOnly: true
     });
-    setTimeout(() => {
-        cm.refresh();
-    }, 300);
+
     return {wrap: newWrap, editor: cm};
 }
 
index 2a9fad8b3023b94212e88ae0a2867a3402fc3917..7a7b2c9bcfce6ce3ae0daf532a9d4be05f8229b7 100644 (file)
@@ -53,6 +53,14 @@ export function onEnterPress(elements, callback) {
     if (!Array.isArray(elements)) {
         elements = [elements];
     }
+
+    const listener = event => {
+        if (event.key === 'Enter') {
+            callback(event);
+        }
+    }
+
+    elements.forEach(e => e.addEventListener('keypress', listener));
 }
 
 /**
@@ -89,4 +97,24 @@ export function findText(selector, text) {
         }
     }
     return null;
+}
+
+/**
+ * Show a loading indicator in the given element.
+ * This will effectively clear the element.
+ * @param {Element} element
+ */
+export function showLoading(element) {
+    element.innerHTML = `<div class="loading-container"><div></div><div></div><div></div></div>`;
+}
+
+/**
+ * Remove any loading indicators within the given element.
+ * @param {Element} element
+ */
+export function removeLoading(element) {
+    const loadingEls = element.querySelectorAll('.loading-container');
+    for (const el of loadingEls) {
+        el.remove();
+    }
 }
\ No newline at end of file
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 fa3ed7fdfcb55ebd341ee44543eafd58b697d14d..6668014e7b6913ca4fbee93fe11f69307ada3349 100644 (file)
@@ -1,55 +1,66 @@
+const listeners = {};
+const stack = [];
+
 /**
- * Simple global events manager
+ * Emit a custom event for any handlers to pick-up.
+ * @param {String} eventName
+ * @param {*} eventData
  */
-class Events {
-    constructor() {
-        this.listeners = {};
-        this.stack = [];
+function emit(eventName, eventData) {
+    stack.push({name: eventName, data: eventData});
+    if (typeof listeners[eventName] === 'undefined') return this;
+    let eventsToStart = listeners[eventName];
+    for (let i = 0; i < eventsToStart.length; i++) {
+        let event = eventsToStart[i];
+        event(eventData);
     }
+}
 
-    /**
-     * Emit a custom event for any handlers to pick-up.
-     * @param {String} eventName
-     * @param {*} eventData
-     * @returns {Events}
-     */
-    emit(eventName, eventData) {
-        this.stack.push({name: eventName, data: eventData});
-        if (typeof this.listeners[eventName] === 'undefined') return this;
-        let eventsToStart = this.listeners[eventName];
-        for (let i = 0; i < eventsToStart.length; i++) {
-            let event = eventsToStart[i];
-            event(eventData);
-        }
-        return this;
-    }
+/**
+ * Listen to a custom event and run the given callback when that event occurs.
+ * @param {String} eventName
+ * @param {Function} callback
+ * @returns {Events}
+ */
+function listen(eventName, callback) {
+    if (typeof listeners[eventName] === 'undefined') listeners[eventName] = [];
+    listeners[eventName].push(callback);
+}
 
-    /**
-     * Listen to a custom event and run the given callback when that event occurs.
-     * @param {String} eventName
-     * @param {Function} callback
-     * @returns {Events}
-     */
-    listen(eventName, callback) {
-        if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
-        this.listeners[eventName].push(callback);
-        return this;
-    }
+/**
+ * Emit an event for public use.
+ * Sends the event via the native DOM event handling system.
+ * @param {Element} targetElement
+ * @param {String} eventName
+ * @param {Object} eventData
+ */
+function emitPublic(targetElement, eventName, eventData) {
+    const event = new CustomEvent(eventName, {
+        detail: eventData,
+        bubbles: true
+    });
+    targetElement.dispatchEvent(event);
+}
 
-    /**
-     * Emit an event for public use.
-     * Sends the event via the native DOM event handling system.
-     * @param {Element} targetElement
-     * @param {String} eventName
-     * @param {Object} eventData
-     */
-    emitPublic(targetElement, eventName, eventData) {
-        const event = new CustomEvent(eventName, {
-            detail: eventData,
-            bubbles: true
-        });
-        targetElement.dispatchEvent(event);
+/**
+ * Notify of a http error.
+ * Check for standard scenarios such as validation errors and
+ * formats an error notification accordingly.
+ * @param {Error} error
+ */
+function showValidationErrors(error) {
+    if (!error.status) return;
+    if (error.status === 422 && error.data) {
+        const message = Object.values(error.data).flat().join('\n');
+        emit('error', message);
     }
 }
 
-export default Events;
\ No newline at end of file
+export default {
+    emit,
+    emitPublic,
+    listen,
+    success: (msg) => emit('success', msg),
+    error: (msg) => emit('error', msg),
+    showValidationErrors,
+}
\ No newline at end of file
index 06dac9864a4d4523987e61f4009075ea34ed0594..00865e69bd8ff83698864ed7c6d0dd0042ef465f 100644 (file)
@@ -67,11 +67,23 @@ async function dataRequest(method, url, data = null) {
         body: data,
     };
 
+    // Send data as JSON if a plain object
     if (typeof data === 'object' && !(data instanceof FormData)) {
-        options.headers = {'Content-Type': 'application/json'};
+        options.headers = {
+            'Content-Type': 'application/json',
+            'X-Requested-With': 'XMLHttpRequest',
+        };
         options.body = JSON.stringify(data);
     }
 
+    // Ensure FormData instances are sent over POST
+    // Since Laravel does not read multipart/form-data from other types
+    // of request. Hence the addition of the magic _method value.
+    if (data instanceof FormData && method !== 'post') {
+        data.append('_method', method);
+        options.method = 'post';
+    }
+
     return request(url, options)
 }
 
@@ -109,7 +121,7 @@ async function request(url, options = {}) {
 
     const response = await fetch(url, options);
     const content = await getResponseContent(response);
-    return {
+    const returnData = {
         data: content,
         headers: response.headers,
         redirected: response.redirected,
@@ -117,18 +129,28 @@ async function request(url, options = {}) {
         statusText: response.statusText,
         url: response.url,
         original: response,
+    };
+
+    if (!response.ok) {
+        throw returnData;
     }
+
+    return returnData;
 }
 
 /**
  * Get the content from a fetch response.
  * Checks the content-type header to determine the format.
- * @param response
+ * @param {Response} response
  * @returns {Promise<Object|String>}
  */
 async function getResponseContent(response) {
-    const responseContentType = response.headers.get('Content-Type');
-    const subType = responseContentType.split('/').pop();
+    if (response.status === 204) {
+        return null;
+    }
+
+    const responseContentType = response.headers.get('Content-Type') || '';
+    const subType = responseContentType.split(';')[0].split('/').pop();
 
     if (subType === 'javascript' || subType === 'json') {
         return await response.json();
index b595a05e6f95b8e9a2d7862adbc5b5d003f6e163..62bb51f56aacb5f0216e0e4621ffdfcae0d34481 100644 (file)
@@ -47,7 +47,19 @@ class Translator {
      */
     getPlural(key, count, replacements) {
         const text = this.getTransText(key);
-        const splitText = text.split('|');
+        return this.parsePlural(text, count, replacements);
+    }
+
+    /**
+     * Parse the given translation and find the correct plural option
+     * to use. Similar format at laravel's 'trans_choice' helper.
+     * @param {String} translation
+     * @param {Number} count
+     * @param {Object} replacements
+     * @returns {String}
+     */
+    parsePlural(translation, count, replacements) {
+        const splitText = translation.split('|');
         const exactCountRegex = /^{([0-9]+)}/;
         const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
         let result = null;
diff --git a/resources/js/vues/attachment-manager.js b/resources/js/vues/attachment-manager.js
deleted file mode 100644 (file)
index 2467c64..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-import draggable from "vuedraggable";
-import dropzone from "./components/dropzone";
-
-function mounted() {
-    this.pageId = this.$el.getAttribute('page-id');
-    this.file = this.newFile();
-
-    this.$http.get(window.baseUrl(`/attachments/get/page/${this.pageId}`)).then(resp => {
-        this.files = resp.data;
-    }).catch(err => {
-        this.checkValidationErrors('get', err);
-    });
-}
-
-let data = {
-    pageId: null,
-    files: [],
-    fileToEdit: null,
-    file: {},
-    tab: 'list',
-    editTab: 'file',
-    errors: {link: {}, edit: {}, delete: {}}
-};
-
-const components = {dropzone, draggable};
-
-let methods = {
-
-    newFile() {
-        return {page_id: this.pageId};
-    },
-
-    getFileUrl(file) {
-        if (file.external && file.path.indexOf('http') !== 0) {
-            return file.path;
-        }
-        return window.baseUrl(`/attachments/${file.id}`);
-    },
-
-    fileSortUpdate() {
-        this.$http.put(window.baseUrl(`/attachments/sort/page/${this.pageId}`), {files: this.files}).then(resp => {
-            this.$events.emit('success', resp.data.message);
-        }).catch(err => {
-            this.checkValidationErrors('sort', err);
-        });
-    },
-
-    startEdit(file) {
-        this.fileToEdit = Object.assign({}, file);
-        this.fileToEdit.link = file.external ? file.path : '';
-        this.editTab = file.external ? 'link' : 'file';
-    },
-
-    deleteFile(file) {
-        if (!file.deleting) {
-            return this.$set(file, 'deleting', true);
-        }
-
-        this.$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
-            this.$events.emit('success', resp.data.message);
-            this.files.splice(this.files.indexOf(file), 1);
-        }).catch(err => {
-            this.checkValidationErrors('delete', err)
-        });
-    },
-
-    uploadSuccess(upload) {
-        this.files.push(upload.data);
-        this.$events.emit('success', trans('entities.attachments_file_uploaded'));
-    },
-
-    uploadSuccessUpdate(upload) {
-        let fileIndex = this.filesIndex(upload.data);
-        if (fileIndex === -1) {
-            this.files.push(upload.data)
-        } else {
-            this.files.splice(fileIndex, 1, upload.data);
-        }
-
-        if (this.fileToEdit && this.fileToEdit.id === upload.data.id) {
-            this.fileToEdit = Object.assign({}, upload.data);
-        }
-        this.$events.emit('success', trans('entities.attachments_file_updated'));
-    },
-
-    checkValidationErrors(groupName, err) {
-        if (typeof err.response.data === "undefined" && typeof err.response.data === "undefined") return;
-        this.errors[groupName] = err.response.data;
-    },
-
-    getUploadUrl(file) {
-        let url = window.baseUrl(`/attachments/upload`);
-        if (typeof file !== 'undefined') url += `/${file.id}`;
-        return url;
-    },
-
-    cancelEdit() {
-        this.fileToEdit = null;
-    },
-
-    attachNewLink(file) {
-        file.uploaded_to = this.pageId;
-        this.errors.link = {};
-        this.$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
-            this.files.push(resp.data);
-            this.file = this.newFile();
-            this.$events.emit('success', trans('entities.attachments_link_attached'));
-        }).catch(err => {
-            this.checkValidationErrors('link', err);
-        });
-    },
-
-    updateFile(file) {
-        $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
-            let search = this.filesIndex(resp.data);
-            if (search === -1) {
-                this.files.push(resp.data);
-            } else {
-                this.files.splice(search, 1, resp.data);
-            }
-
-            if (this.fileToEdit && !file.external) this.fileToEdit.link = '';
-            this.fileToEdit = false;
-
-            this.$events.emit('success', trans('entities.attachments_updated_success'));
-        }).catch(err => {
-            this.checkValidationErrors('edit', err);
-        });
-    },
-
-    filesIndex(file) {
-        for (let i = 0, len = this.files.length; i < len; i++) {
-            if (this.files[i].id === file.id) return i;
-        }
-        return -1;
-    }
-
-};
-
-export default {
-    data, methods, mounted, components,
-};
\ No newline at end of file
diff --git a/resources/js/vues/components/dropzone.js b/resources/js/vues/components/dropzone.js
deleted file mode 100644 (file)
index 1c04572..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-import DropZone from "dropzone";
-import { fadeOut } from "../../services/animations";
-
-const template = `
-    <div class="dropzone-container text-center">
-        <button type="button" class="dz-message">{{placeholder}}</button>
-    </div>
-`;
-
-const props = ['placeholder', 'uploadUrl', 'uploadedTo'];
-
-function mounted() {
-   const container = this.$el;
-   const _this = this;
-   this._dz = new DropZone(container, {
-        addRemoveLinks: true,
-        dictRemoveFile: trans('components.image_upload_remove'),
-        timeout: Number(window.uploadTimeout) || 60000,
-        maxFilesize: Number(window.uploadLimit) || 256,
-        url: function() {
-            return _this.uploadUrl;
-        },
-        init: function () {
-            const dz = this;
-
-            dz.on('sending', function (file, xhr, data) {
-                const token = window.document.querySelector('meta[name=token]').getAttribute('content');
-                data.append('_token', token);
-                const uploadedTo = typeof _this.uploadedTo === 'undefined' ? 0 : _this.uploadedTo;
-                data.append('uploaded_to', uploadedTo);
-
-                xhr.ontimeout = function (e) {
-                    dz.emit('complete', file);
-                    dz.emit('error', file, trans('errors.file_upload_timeout'));
-                }
-            });
-
-            dz.on('success', function (file, data) {
-                _this.$emit('success', {file, data});
-                fadeOut(file.previewElement, 800, () => {
-                    dz.removeFile(file);
-                });
-            });
-
-            dz.on('error', function (file, errorMessage, xhr) {
-                _this.$emit('error', {file, errorMessage, xhr});
-
-                function setMessage(message) {
-                    const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
-                    messsageEl.textContent = message;
-                }
-
-                if (xhr && xhr.status === 413) {
-                    setMessage(trans('errors.server_upload_limit'))
-                } else if (errorMessage.file) {
-                    setMessage(errorMessage.file);
-                }
-
-            });
-        }
-   });
-}
-
-function data() {
-    return {};
-}
-
-const methods = {
-    onClose: function () {
-        this._dz.removeAllFiles(true);
-    }
-};
-
-export default {
-    template,
-    props,
-    mounted,
-    data,
-    methods
-};
diff --git a/resources/js/vues/image-manager.js b/resources/js/vues/image-manager.js
deleted file mode 100644 (file)
index b877345..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-import * as Dates from "../services/dates";
-import dropzone from "./components/dropzone";
-
-let page = 1;
-let previousClickTime = 0;
-let previousClickImage = 0;
-let dataLoaded = false;
-let callback = false;
-let baseUrl = '';
-
-let preSearchImages = [];
-let preSearchHasMore = false;
-
-const data = {
-    images: [],
-
-    imageType: false,
-    uploadedTo: false,
-
-    selectedImage: false,
-    dependantPages: false,
-    showing: false,
-    filter: null,
-    hasMore: false,
-    searching: false,
-    searchTerm: '',
-
-    imageUpdateSuccess: false,
-    imageDeleteSuccess: false,
-    deleteConfirm: false,
-};
-
-const methods = {
-
-    show(providedCallback, imageType = null) {
-        callback = providedCallback;
-        this.showing = true;
-        this.$el.children[0].components.popup.show();
-
-        // Get initial images if they have not yet been loaded in.
-        if (dataLoaded && imageType === this.imageType) return;
-        if (imageType) {
-            this.imageType = imageType;
-            this.resetState();
-        }
-        this.fetchData();
-        dataLoaded = true;
-    },
-
-    hide() {
-        if (this.$refs.dropzone) {
-            this.$refs.dropzone.onClose();
-        }
-        this.showing = false;
-        this.selectedImage = false;
-        this.$el.children[0].components.popup.hide();
-    },
-
-    async fetchData() {
-        const params = {
-            page,
-            search: this.searching ? this.searchTerm : null,
-            uploaded_to: this.uploadedTo || null,
-            filter_type: this.filter,
-        };
-
-        const {data} = await this.$http.get(baseUrl, params);
-        this.images = this.images.concat(data.images);
-        this.hasMore = data.has_more;
-        page++;
-    },
-
-    setFilterType(filterType) {
-        this.filter = filterType;
-        this.resetState();
-        this.fetchData();
-    },
-
-    resetState() {
-        this.cancelSearch();
-        this.resetListView();
-        this.deleteConfirm = false;
-        baseUrl = window.baseUrl(`/images/${this.imageType}`);
-    },
-
-    resetListView() {
-        this.images = [];
-        this.hasMore = false;
-        page = 1;
-    },
-
-    searchImages() {
-        if (this.searchTerm === '') return this.cancelSearch();
-
-        // Cache current settings for later
-        if (!this.searching) {
-            preSearchImages = this.images;
-            preSearchHasMore = this.hasMore;
-        }
-
-        this.searching = true;
-        this.resetListView();
-        this.fetchData();
-    },
-
-    cancelSearch() {
-        if (!this.searching) return;
-        this.searching = false;
-        this.searchTerm = '';
-        this.images = preSearchImages;
-        this.hasMore = preSearchHasMore;
-    },
-
-    imageSelect(image) {
-        const dblClickTime = 300;
-        const currentTime = Date.now();
-        const timeDiff = currentTime - previousClickTime;
-        const isDblClick = timeDiff < dblClickTime && image.id === previousClickImage;
-
-        if (isDblClick) {
-            this.callbackAndHide(image);
-        } else {
-            this.selectedImage = image;
-            this.deleteConfirm = false;
-            this.dependantPages = false;
-        }
-
-        previousClickTime = currentTime;
-        previousClickImage = image.id;
-    },
-
-    callbackAndHide(imageResult) {
-        if (callback) callback(imageResult);
-        this.hide();
-    },
-
-    async saveImageDetails() {
-        let url = window.baseUrl(`/images/${this.selectedImage.id}`);
-        try {
-            await this.$http.put(url, this.selectedImage)
-        } catch (error) {
-            if (error.response.status === 422) {
-                let errors = error.response.data;
-                let message = '';
-                Object.keys(errors).forEach((key) => {
-                    message += errors[key].join('\n');
-                });
-                this.$events.emit('error', message);
-            }
-        }
-    },
-
-    async deleteImage() {
-
-        if (!this.deleteConfirm) {
-            const url = window.baseUrl(`/images/usage/${this.selectedImage.id}`);
-            try {
-                const {data} = await this.$http.get(url);
-                this.dependantPages = data;
-            } catch (error) {
-                console.error(error);
-            }
-            this.deleteConfirm = true;
-            return;
-        }
-
-        const url = window.baseUrl(`/images/${this.selectedImage.id}`);
-        await this.$http.delete(url);
-        this.images.splice(this.images.indexOf(this.selectedImage), 1);
-        this.selectedImage = false;
-        this.$events.emit('success', trans('components.image_delete_success'));
-        this.deleteConfirm = false;
-    },
-
-    getDate(stringDate) {
-        return Dates.formatDateTime(new Date(stringDate));
-    },
-
-    uploadSuccess(event) {
-        this.images.unshift(event.data);
-        this.$events.emit('success', trans('components.image_upload_success'));
-    },
-};
-
-const computed = {
-    uploadUrl() {
-        return window.baseUrl(`/images/${this.imageType}`);
-    }
-};
-
-function mounted() {
-    window.ImageManager = this;
-    this.imageType = this.$el.getAttribute('image-type');
-    this.uploadedTo = this.$el.getAttribute('uploaded-to');
-    baseUrl = window.baseUrl('/images/' + this.imageType)
-}
-
-export default {
-    mounted,
-    methods,
-    data,
-    computed,
-    components: {dropzone},
-};
diff --git a/resources/js/vues/page-editor.js b/resources/js/vues/page-editor.js
deleted file mode 100644 (file)
index a79ad20..0000000
+++ /dev/null
@@ -1,167 +0,0 @@
-import * as Dates from "../services/dates";
-
-let autoSaveFrequency = 30;
-
-let autoSave = false;
-let draftErroring = false;
-
-let currentContent = {
-    title: false,
-    html: false
-};
-
-let lastSave = 0;
-
-function mounted() {
-    let elem = this.$el;
-    this.draftsEnabled = elem.getAttribute('drafts-enabled') === 'true';
-    this.editorType = elem.getAttribute('editor-type');
-    this.pageId= Number(elem.getAttribute('page-id'));
-    this.isNewDraft = Number(elem.getAttribute('page-new-draft')) === 1;
-    this.isUpdateDraft = Number(elem.getAttribute('page-update-draft')) === 1;
-    this.titleElem = elem.querySelector('input[name=name]');
-    this.hasDefaultTitle = this.titleElem.closest('[is-default-value]') !== null;
-
-    if (this.pageId !== 0 && this.draftsEnabled) {
-        window.setTimeout(() => {
-            this.startAutoSave();
-        }, 1000);
-    }
-
-    if (this.isUpdateDraft || this.isNewDraft) {
-        this.draftText = trans('entities.pages_editing_draft');
-    } else {
-        this.draftText = trans('entities.pages_editing_page');
-    }
-
-    // Listen to save events from editor
-    window.$events.listen('editor-save-draft', this.saveDraft);
-    window.$events.listen('editor-save-page', this.savePage);
-
-    // Listen to content changes from the editor
-    window.$events.listen('editor-html-change', html => {
-        this.editorHTML = html;
-    });
-    window.$events.listen('editor-markdown-change', markdown => {
-        this.editorMarkdown = markdown;
-    });
-
-    this.setInitialFocus();
-}
-
-let data = {
-    draftsEnabled: false,
-    editorType: 'wysiwyg',
-    pagedId: 0,
-    isNewDraft: false,
-    isUpdateDraft: false,
-
-    draftText: '',
-    draftUpdated : false,
-    changeSummary: '',
-
-    editorHTML: '',
-    editorMarkdown: '',
-
-    hasDefaultTitle: false,
-    titleElem: null,
-};
-
-let methods = {
-
-    setInitialFocus() {
-        if (this.hasDefaultTitle) {
-            this.titleElem.select();
-        } else {
-            window.setTimeout(() => {
-                this.$events.emit('editor::focus', '');
-            }, 500);
-        }
-    },
-
-    startAutoSave() {
-        currentContent.title = this.titleElem.value.trim();
-        currentContent.html = this.editorHTML;
-
-        autoSave = window.setInterval(() => {
-            // Return if manually saved recently to prevent bombarding the server
-            if (Date.now() - lastSave < (1000 * autoSaveFrequency)/2) return;
-            const newTitle = this.titleElem.value.trim();
-            const newHtml = this.editorHTML;
-
-            if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
-                currentContent.html = newHtml;
-                currentContent.title = newTitle;
-                this.saveDraft();
-            }
-
-        }, 1000 * autoSaveFrequency);
-    },
-
-    saveDraft() {
-        if (!this.draftsEnabled) return;
-
-        const data = {
-            name: this.titleElem.value.trim(),
-            html: this.editorHTML
-        };
-
-        if (this.editorType === 'markdown') data.markdown = this.editorMarkdown;
-
-        const url = window.baseUrl(`/ajax/page/${this.pageId}/save-draft`);
-        window.$http.put(url, data).then(response => {
-            draftErroring = false;
-            if (!this.isNewDraft) this.isUpdateDraft = true;
-            this.draftNotifyChange(`${response.data.message} ${Dates.utcTimeStampToLocalTime(response.data.timestamp)}`);
-            lastSave = Date.now();
-        }, errorRes => {
-            if (draftErroring) return;
-            window.$events.emit('error', trans('errors.page_draft_autosave_fail'));
-            draftErroring = true;
-        });
-    },
-
-    savePage() {
-        this.$el.closest('form').submit();
-    },
-
-    draftNotifyChange(text) {
-        this.draftText = text;
-        this.draftUpdated = true;
-        window.setTimeout(() => {
-            this.draftUpdated = false;
-        }, 2000);
-    },
-
-    discardDraft() {
-        let url = window.baseUrl(`/ajax/page/${this.pageId}`);
-        window.$http.get(url).then(response => {
-            if (autoSave) window.clearInterval(autoSave);
-
-            this.draftText = trans('entities.pages_editing_page');
-            this.isUpdateDraft = false;
-            window.$events.emit('editor-html-update', response.data.html);
-            window.$events.emit('editor-markdown-update', response.data.markdown || response.data.html);
-
-            this.titleElem.value = response.data.name;
-            window.setTimeout(() => {
-                this.startAutoSave();
-            }, 1000);
-            window.$events.emit('success', trans('entities.pages_draft_discarded'));
-        });
-    },
-
-};
-
-let computed = {
-    changeSummaryShort() {
-        let len = this.changeSummary.length;
-        if (len === 0) return trans('entities.pages_edit_set_changelog');
-        if (len <= 16) return this.changeSummary;
-        return this.changeSummary.slice(0, 16) + '...';
-    }
-};
-
-export default {
-    mounted, data, methods, computed,
-};
\ No newline at end of file
diff --git a/resources/js/vues/vues.js b/resources/js/vues/vues.js
deleted file mode 100644 (file)
index 0d3817f..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from "vue";
-
-function exists(id) {
-    return document.getElementById(id) !== null;
-}
-
-import imageManager from "./image-manager";
-import attachmentManager from "./attachment-manager";
-import pageEditor from "./page-editor";
-
-let vueMapping = {
-    'image-manager': imageManager,
-    'attachment-manager': attachmentManager,
-    'page-editor': pageEditor,
-};
-
-window.vues = {};
-
-function load() {
-    let ids = Object.keys(vueMapping);
-    for (let i = 0, len = ids.length; i < len; i++) {
-        if (!exists(ids[i])) continue;
-        let config = vueMapping[ids[i]];
-        config.el = '#' + ids[i];
-        window.vues[ids[i]] = new Vue(config);
-    }
-}
-
-export default load;
-
-
-
index 348eba3985de052b0d1dd47d16436ecd8a18f3df..18ca165718f782a8f7cc083e6a42561e0b52605f 100644 (file)
@@ -33,16 +33,25 @@ return [
     'book_delete'                 => 'تم حذف الكتاب',
     'book_delete_notification'    => 'تم حذف الكتاب بنجاح',
     'book_sort'                   => 'تم سرد الكتاب',
-    'book_sort_notification'      => 'تÙ\85ت Ø¥Ø¹Ø§Ø¯Ø© سرد الكتاب بنجاح',
+    'book_sort_notification'      => 'Ø£Ù\8fعÙ\90Ù\8aدÙ\8e سرد الكتاب بنجاح',
 
     // 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" 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',
 
     // Other
     'commented_on'                => 'تم التعليق',
+    'permissions_update'          => 'تحديث الأذونات',
 ];
index afe089fa0851be526b72ebc9382450c83b3c85dd..c0cc8bbc0e76bfa948d67a4c66a8f63c58b7ff04 100644 (file)
@@ -26,8 +26,8 @@ return [
     'remember_me' => 'تذكرني',
     'ldap_email_hint' => 'الرجاء إدخال عنوان بريد إلكتروني لاستخدامه مع الحساب.',
     'create_account' => 'إنشاء حساب',
-    'already_have_account' => 'Already have an account?',
-    'dont_have_account' => 'Don\'t have an account?',
+    'already_have_account' => 'لديك حساب بالفعل؟',
+    'dont_have_account' => 'ليس لديك حساب؟',
     'social_login' => 'تسجيل الدخول باستخدام حسابات التواصل الاجتماعي',
     'social_registration' => 'إنشاء حساب باستخدام حسابات التواصل الاجتماعي',
     'social_registration_text' => 'إنشاء حساب والدخول باستخدام خدمة أخرى.',
@@ -43,11 +43,11 @@ return [
     'reset_password' => 'استعادة كلمة المرور',
     'reset_password_send_instructions' => 'أدخل بريدك الإلكتروني بالأسفل وسيتم إرسال رسالة برابط لاستعادة كلمة المرور.',
     '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' => 'سيتم إرسال رابط إعادة تعيين كلمة المرور إلى عنوان البريد الإلكتروني هذا إذا كان موجودًا في النظام.',
     'reset_password_success' => 'تمت استعادة كلمة المرور بنجاح.',
     'email_reset_subject' => 'استعد كلمة المرور الخاصة بتطبيق :appName',
     'email_reset_text' => 'تم إرسال هذه الرسالة بسبب تلقينا لطلب استعادة كلمة المرور الخاصة بحسابكم.',
-    'email_reset_not_requested' => 'إذا لم يتم طلب استعادة كلمة المرور من قبلكم, فلا حاجة لاتخاذ أية خطوات.',
+    'email_reset_not_requested' => 'إذا لم يتم طلب استعادة كلمة المرور من قبلكم، فلا حاجة لاتخاذ أية خطوات.',
 
 
     // Email Confirmation
@@ -62,16 +62,51 @@ return [
     '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' => '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!'
+    '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!',
+
+    // 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.',
 ];
\ No newline at end of file
index 193844390f9b933e88beabca0ba92dab552378ff..de40e583e1461fadc6787e4c2689e757d139e302 100644 (file)
@@ -11,7 +11,7 @@ return [
     'save' => 'حفظ',
     'continue' => 'استمرار',
     'select' => 'تحديد',
-    'toggle_all' => 'Toggle All',
+    'toggle_all' => 'تبديل الكل',
     'more' => 'المزيد',
 
     // Form Labels
@@ -24,7 +24,7 @@ return [
     // Actions
     'actions' => 'إجراءات',
     'view' => 'عرض',
-    'view_all' => 'View All',
+    'view_all' => 'عرض الكل',
     'create' => 'إنشاء',
     'update' => 'تحديث',
     'edit' => 'تعديل',
@@ -33,47 +33,63 @@ return [
     'copy' => 'نسخ',
     'reply' => 'رد',
     'delete' => 'حذف',
+    'delete_confirm' => 'تأكيد الحذف',
     'search' => 'بحث',
     'search_clear' => 'مسح البحث',
     'reset' => 'إعادة تعيين',
     'remove' => 'إزالة',
     'add' => 'إضافة',
-    'fullscreen' => 'Fullscreen',
+    'configure' => 'Configure',
+    'fullscreen' => 'شاشة كاملة',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
-    'sort_options' => 'Sort Options',
-    'sort_direction_toggle' => 'Sort Direction Toggle',
-    'sort_ascending' => 'Sort Ascending',
-    'sort_descending' => 'Sort Descending',
-    'sort_name' => 'Name',
-    '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' => 'حذÙ\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' => 'التفاصيل',
     'grid_view' => 'عرض شبكي',
     'list_view' => 'عرض منسدل',
-    'default' => 'Default',
-    'breadcrumb' => 'Breadcrumb',
+    'default' => 'افتراضي',
+    'breadcrumb' => 'شريط التنقل',
 
     // Header
-    '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_content' => 'Content',
+    'tab_info' => 'معلومات',
+    'tab_info_label' => 'تبويب: إظهار المعلومات الثانوية',
+    'tab_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' => 'سياسة الخصوصية',
+    'terms_of_service' => 'اتفاقية شروط الخدمة',
 ];
index aa3935bd900f5e7f4dba7054fd863a3fae08dbfb..803232b2d00e94706b151da31f4f1647dbebad17 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'المزيد',
     'image_image_name' => 'اسم الصورة',
     'image_delete_used' => 'هذه الصورة مستخدمة بالصفحات أدناه.',
-    'image_delete_confirm' => 'اضغط زر الحذف مرة أخرى لتأكيد حذف هذه الصورة.',
+    'image_delete_confirm_text' => 'هل أنت متأكد من أنك تريد حذف هذه الصورة؟',
     'image_select_image' => 'تحديد الصورة',
     'image_dropzone' => 'قم بإسقاط الصورة أو اضغط هنا للرفع',
     'images_deleted' => 'تم حذف الصور',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'تعديل الشفرة',
     'code_language' => 'لغة الشفرة',
     'code_content' => 'محتويات الشفرة',
+    'code_session_history' => 'سجل الدورة',
     'code_save' => 'حفظ الشفرة',
 ];
index 9278c8cf37caee241dfa456457f1b1948046ce92..7e6dfc7a18649069a58bf6bbec6ed9bab2efa2af 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,23 +22,28 @@ return [
     'meta_created_name' => 'أنشئ :timeLength بواسطة :user',
     'meta_updated' => 'مُحدث :timeLength',
     'meta_updated_name' => 'مُحدث :timeLength بواسطة :user',
-    'entity_select' => 'Entity Select',
+    'meta_owned_name' => 'Owned by :user',
+    'entity_select' => 'اختيار الكيان',
     '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',
 
     // Search
     'search_results' => 'نتائج البحث',
@@ -47,17 +52,19 @@ return [
     'search_no_pages' => 'لم يطابق بحثكم أي صفحة',
     'search_for_term' => 'ابحث عن :term',
     'search_more' => 'المزيد من النتائج',
-    'search_filters' => 'تصفية البحث',
+    'search_advanced' => 'بحث مفصل',
+    'search_terms' => 'البحث باستخدام المصطلحات',
     'search_content_type' => 'نوع المحتوى',
     'search_exact_matches' => 'نتائج مطابقة تماماً',
     'search_tags' => 'بحث الوسوم',
-    'search_options' => 'Options',
-    'search_viewed_by_me' => 'تÙ\85 Ø§Ø³ØªØ¹Ø±Ø§Ø¶Ù\87ا من قبلي',
-    'search_not_viewed_by_me' => 'لم يتم استعراضها من قبلي',
+    'search_options' => 'الخيارات',
+    'search_viewed_by_me' => 'استعرضت من قبلي',
+    'search_not_viewed_by_me' => 'لم تستعرض من قبلي',
     'search_permissions_set' => 'حزمة الأذونات',
     'search_created_by_me' => 'أنشئت بواسطتي',
     'search_updated_by_me' => 'حُدثت بواسطتي',
-    'search_date_options' => 'Date Options',
+    'search_owned_by_me' => 'Owned by me',
+    'search_date_options' => 'خيارات التاريخ',
     'search_updated_before' => 'حدثت قبل',
     'search_updated_after' => 'حدثت بعد',
     'search_created_before' => 'أنشئت قبل',
@@ -66,52 +73,53 @@ 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' => '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' => 'تم نسخ أذونات رف الكتب إلى :count books',
 
     // Books
     'book' => 'كتاب',
-    'books' => 'كتب',
+    'books' => 'الكتب',
     'x_books' => ':count كتاب|:count كتب',
     'books_empty' => 'لم يتم إنشاء أي كتب',
     'books_popular' => 'كتب رائجة',
     'books_recent' => 'كتب حديثة',
     'books_new' => 'كتب جديدة',
-    'books_new_action' => 'New Book',
+    'books_new_action' => 'كتاب جديد',
     'books_popular_empty' => 'الكتب الأكثر رواجاً ستظهر هنا.',
     'books_new_empty' => 'الكتب المنشأة مؤخراً ستظهر هنا.',
     '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',
@@ -128,11 +136,11 @@ return [
     'books_navigation' => 'تصفح الكتاب',
     'books_sort' => 'فرز محتويات الكتاب',
     'books_sort_named' => 'فرز كتاب :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' => 'ترتيب حسب الإسم',
+    'books_sort_created' => 'ترتيب حسب تاريخ الإنشاء',
+    'books_sort_updated' => 'فرز حسب تاريخ التحديث',
+    'books_sort_chapters_first' => 'الفصول الأولى',
+    'books_sort_chapters_last' => 'الفصول الأخيرة',
     'books_sort_show_other' => 'عرض كتب أخرى',
     'books_sort_save' => 'حفظ الترتيب الجديد',
 
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'إنشاء فصل جديد',
     'chapters_delete' => 'حذف الفصل',
     'chapters_delete_named' => 'حذف فصل :chapterName',
-    'chapters_delete_explain' => 'سيتم حذف فصل \':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' => 'تأكيد حذف الفصل؟',
     'chapters_edit' => 'تعديل الفصل',
     'chapters_edit_named' => 'تعديل فصل :chapterName',
@@ -176,7 +184,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' => 'المسودة قيد التعديل',
@@ -193,7 +201,7 @@ return [
     'pages_md_editor' => 'المحرر',
     'pages_md_preview' => 'معاينة',
     'pages_md_insert_image' => 'إدخال صورة',
-    'pages_md_insert_link' => 'Insert Entity Link',
+    'pages_md_insert_link' => 'إدراج ارتباط الكيان',
     'pages_md_insert_drawing' => 'إدخال رسمة',
     'pages_not_in_chapter' => 'صفحة ليست في فصل',
     'pages_move' => 'نقل الصفحة',
@@ -207,11 +215,12 @@ return [
     'pages_revisions' => 'مراجعات الصفحة',
     'pages_revisions_named' => 'مراجعات صفحة :pageName',
     'pages_revision_named' => 'مراجعة صفحة :pageName',
+    'pages_revision_restored_from' => '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_numbered' => 'مراجعة #:id',
+    'pages_revisions_numbered_changes' => 'مراجعة #: رقم تعريفي التغييرات',
     'pages_revisions_changelog' => 'سجل التعديل',
     'pages_revisions_changes' => 'التعديلات',
     'pages_revisions_current' => 'النسخة الحالية',
@@ -223,47 +232,48 @@ 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_edit_active' => [
         'start_a' => ':count من المستخدمين بدأوا بتعديل هذه الصفحة',
         'start_b' => ':userName بدأ بتعديل هذه الصفحة',
         'time_a' => 'منذ أن تم تحديث هذه الصفحة',
         'time_b' => 'في آخر :minCount دقيقة/دقائق',
-        'message' => ':start :time. Take care not to overwrite each other\'s updates!',
+        'message' => 'وقت البدء: احرص على عدم الكتابة فوق تحديثات بعضنا البعض!',
     ],
-    'pages_draft_discarded' => 'تم التخلص من المسودة. تم تحديث المحرر بمحتوى الصفحة الحالي',
-    'pages_specific' => 'Specific Page',
-    'pages_is_template' => 'Page Template',
+    'pages_draft_discarded' => 'تم التخلص من المسودة وتحديث المحرر بمحتوى الصفحة الحالي',
+    'pages_specific' => 'صفحة محددة',
+    'pages_is_template' => 'قالب الصفحة',
 
     // Editor Sidebar
     'page_tags' => 'وسوم الصفحة',
     'chapter_tags' => 'وسوم الفصل',
     'book_tags' => 'وسوم الكتاب',
-    'shelf_tags' => 'Shelf Tags',
+    'shelf_tags' => 'علامات الرف',
     'tag' => 'وسم',
     'tags' =>  'وسوم',
-    'tag_name' =>  'Tag Name',
+    'tag_name' =>  'اسم العلامة',
     'tag_value' => 'قيمة الوسم (اختياري)',
     'tags_explain' => "إضافة الوسوم تساعد بترتيب وتقسيم المحتوى. \n من الممكن وضع قيمة لكل وسم لترتيب أفضل وأدق.",
     'tags_add' => 'إضافة وسم آخر',
-    'tags_remove' => 'Remove this tag',
+    'tags_remove' => 'إزالة هذه العلامة',
     '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_confirm' => 'اضغط على زر الحذف مرة أخرى لتأكيد حذف المرفق.',
+    'attachments_delete' => 'هل أنت متأكد من أنك تريد حذف هذا المرفق؟',
     'attachments_dropzone' => 'أسقط الملفات أو اضغط هنا لإرفاق ملف',
-    'attachments_no_files' => 'لم يتم رفع أي ملفات',
+    'attachments_no_files' => 'لم تُرفع أي ملفات',
     'attachments_explain_link' => 'بالإمكان إرفاق رابط في حال عدم تفضيل رفع ملف. قد يكون الرابط لصفحة أخرى أو لملف في أحد خدمات التخزين السحابي.',
     'attachments_link_name' => 'اسم الرابط',
     'attachment_link' => 'رابط المرفق',
-    'attachments_link_url' => 'Link to file',
+    'attachments_link_url' => 'رابط الملف',
     'attachments_link_url_hint' => 'رابط الموقع أو الملف',
-    'attach' => 'Attach',
+    'attach' => 'إرفاق',
+    'attachments_insert_link' => 'إضافة رابط مرفق إلى الصفحة',
     'attachments_edit_file' => 'تعديل الملف',
     'attachments_edit_file_name' => 'اسم الملف',
     'attachments_edit_drop_upload' => 'أسقط الملفات أو اضغط هنا للرفع والاستبدال',
@@ -273,27 +283,27 @@ 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' => 'User for :time',
+    '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 has not created any shelves',
+    'profile_not_created_shelves' => 'لم يقم "اسم المستخدم"بإنشاء أي أرفف',
 
     // Comments
     'comment' => 'تعليق',
     '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' => 'جار حذف التعليق...',
@@ -307,8 +317,8 @@ return [
     '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.'
-];
\ No newline at end of file
+    'revision_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف هذه المراجعة؟',
+    'revision_restore_confirm' => 'هل أنت متأكد من أنك تريد استعادة هذه المراجعة؟ سيتم استبدال محتوى الصفحة الحالية.',
+    'revision_delete_success' => 'تم حذف المراجعة',
+    'revision_cannot_delete_latest' => 'لايمكن حذف آخر مراجعة.'
+];
index 2714d3dbbca7c3aa397e8bc970af0ca245341cb1..829571c585c921ad489a9e8a3f99a3690fd68b98 100644 (file)
@@ -13,16 +13,16 @@ return [
     'email_already_confirmed' => 'تم تأكيد البريد الإلكتروني من قبل, الرجاء محاولة تسجيل الدخول.',
     '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 باستخدام dn و password المعطاة',
     'ldap_extension_not_installed' => 'لم يتم تثبيت إضافة LDAP PHP',
     'ldap_cannot_connect' => 'لا يمكن الاتصال بخادم ldap, فشل الاتصال المبدئي',
-    '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',
+    'saml_already_logged_in' => 'تم تسجيل الدخول بالفعل',
+    'saml_user_not_registered' => 'المستخدم :name غير مسجل ويتم تعطيل التسجيل التلقائي',
+    'saml_no_email_address' => 'تعذر العثور على عنوان بريد إلكتروني، لهذا المستخدم، في البيانات المقدمة من نظام المصادقة الخارجي',
+    'saml_invalid_response_id' => 'لم يتم التعرف على الطلب من نظام التوثيق الخارجي من خلال عملية تبدأ بهذا التطبيق. العودة بعد تسجيل الدخول يمكن أن يسبب هذه المشكلة.',
+    'saml_fail_authed' => 'تسجيل الدخول باستخدام :system فشل، النظام لم يوفر التفويض الناجح',
     'social_no_action_defined' => 'لم يتم تعريف أي إجراء',
     'social_login_bad_response' => "حصل خطأ خلال تسجيل الدخول باستخدام :socialAccount \n:error",
     'social_account_in_use' => 'حساب :socialAccount قيد الاستخدام حالياً, الرجاء محاولة الدخول باستخدام خيار :socialAccount.',
@@ -31,9 +31,9 @@ return [
     'social_account_already_used_existing' => 'حساب :socialAccount مستخدَم من قبل مستخدم آخر.',
     'social_account_not_used' => 'حساب :socialAccount غير مرتبط بأي مستخدم. الرجاء ربطه من خلال إعدادات ملفكم. ',
     'social_account_register_instructions' => 'إذا لم يكن لديكم حساب فيمكنكم التجسيل باستخدام خيار :socialAccount.',
-    '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.',
+    'social_driver_not_found' => 'لم يتم العثور على السوشيال درايفر "Social driver"',
+    'social_driver_not_configured' => 'لم يتم تهيئة إعدادات حسابك الاجتماعي بشكل صحيح.',
+    'invite_token_expired' => 'انتهت صلاحية رابط هذه الدعوة. يمكنك بدلاً من ذلك محاولة إعادة تعيين كلمة مرور حسابك.',
 
     // System
     'path_not_writable' => 'لا يمكن الرفع إلى مسار :filePath. الرجاء التأكد من قابلية الكتابة إلى الخادم.',
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'انتهت عملية تحميل الملف.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
     'attachment_not_found' => 'لم يتم العثور على المرفق',
 
     // Pages
@@ -54,8 +53,8 @@ return [
     'page_custom_home_deletion' => 'لا يمكن حذف الصفحة إذا كانت محددة كصفحة رئيسية',
 
     // Entities
-    'entity_not_found' => 'Entity not found',
-    'bookshelf_not_found' => 'Bookshelf not found',
+    'entity_not_found' => 'الكيان غير موجود',
+    'bookshelf_not_found' => 'رف الكتب غير موجود',
     'book_not_found' => 'لم يتم العثور على الكتاب',
     'page_not_found' => 'لم يتم العثور على الصفحة',
     'chapter_not_found' => 'لم يتم العثور على الفصل',
@@ -71,7 +70,7 @@ 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' => 'حصل خطأ خلال جلب التعليقات.',
@@ -83,21 +82,24 @@ return [
     // 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',
+    '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 لا يعمل حالياً',
     '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 db3dce49fa42490110a079d3d1bdd191b153bdd8..4ab447d5d56e5c3226063fbd6f085111d9cdb34e 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'يجب أن تتكون كلمة المرور من ستة أحرف على الأقل وأن تطابق التأكيد.',
     'user' => "لم يتم العثور على مستخدم بعنوان البريد الإلكتروني المعطى.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'رمز إعادة تعيين كلمة المرور غير صالح لعنوان هذا البريد الإلكتروني.',
     'sent' => 'تم إرسال رابط تجديد كلمة المرور إلى بريدكم الإلكتروني!',
     'reset' => 'تم تجديد كلمة المرور الخاصة بكم!',
 
index 0a689c5d51e961968a83c14c9dd30d6c8a56b4da..2ceb849bc256e1b1a3be4dae8d17bfecf58ed425 100755 (executable)
@@ -12,74 +12,117 @@ return [
     'settings_save_success' => 'تم حفظ الإعدادات',
 
     // App Settings
-    'app_customization' => 'Customization',
-    'app_features_security' => 'Features & Security',
+    'app_customization' => 'تخصيص',
+    'app_features_security' => 'الميزات و الأمان',
     'app_name' => 'اسم التطبيق',
     'app_name_desc' => 'سيتم عرض هذا الاسم في الترويسة وفي أي رسالة بريد إلكتروني.',
     'app_name_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_access' => 'الوصول العام',
+    'app_public_access_desc' => 'تمكين هذا الخيار سيسمح للزوار، الذين لم يتم تسجيل دخولهم، بالوصول إلى المحتوى في مثيل مكدس الكتب الخاص بك.',
+    'app_public_access_desc_guest' => 'يمكن التحكم في وصول الزوار العموميين من خلال المستخدم "الضيف".',
+    'app_public_access_toggle' => 'السماح بالوصول العام',
     'app_public_viewing' => 'السماح بالعرض على العامة؟',
     'app_secure_images' => 'تفعيل حماية أكبر لرفع الصور؟',
-    'app_secure_images_toggle' => 'Enable higher security image uploads',
+    'app_secure_images_toggle' => 'لمزيد من الحماية',
     'app_secure_images_desc' => 'لتحسين أداء النظام, ستكون جميع الصور متاحة للعامة. هذا الخيار يضيف سلسلة من الحروف والأرقام العشوائية صعبة التخمين إلى رابط الصورة. الرجاء التأكد من تعطيل فهرسة المسارات لمنع الوصول السهل.',
     'app_editor' => 'محرر الصفحة',
     'app_editor_desc' => 'الرجاء اختيار محرر النص الذي سيستخدم من قبل جميع المستخدمين لتحرير الصفحات.',
     '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_custom_html_desc' => 'سيتم إدراج أي محتوى مضاف هنا في الجزء السفلي من قسم <head> من كل صفحة. هذا أمر مفيد لتجاوز الأنماط أو إضافة رمز التحليل.',
+    'app_custom_html_disabled_notice' => 'تم تعطيل محتوى HTML الرئيسي المخصص في صفحة الإعدادات هذه لضمان عكس أي تغييرات متتالية.',
     'app_logo' => 'شعار التطبيق',
     'app_logo_desc' => 'يجب أن تكون الصورة بارتفاع 43 بكسل. <br>سيتم تصغير الصور الأكبر من ذلك.',
     'app_primary_color' => 'اللون الأساسي للتطبيق',
     'app_primary_color_desc' => 'يجب أن تكون القيمة من نوع hex. <br>اترك الخانة فارغة للرجوع للون الافتراضي.',
     'app_homepage' => 'الصفحة الرئيسية للتطبيق',
     'app_homepage_desc' => 'الرجاء اختيار صفحة لتصبح الصفحة الرئيسية بدل من الافتراضية. سيتم تجاهل جميع الأذونات الخاصة بالصفحة المختارة.',
-    'app_homepage_select' => 'Select a page',
+    'app_homepage_select' => 'اختر صفحة',
+    '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' => 'تعطيل التعليقات',
-    'app_disable_comments_toggle' => 'Disable comments',
+    'app_disable_comments_toggle' => 'تعطيل التعليقات',
     'app_disable_comments_desc' => 'تعطيل التعليقات على جميع الصفحات داخل التطبيق. التعليقات الموجودة من الأصل لن تكون ظاهرة.',
 
     // 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' => 'إعدادات التسجيل',
-    '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' => 'تمكين التسجيل',
+    'reg_enable_toggle' => 'تمكين التسجيل',
+    'reg_enable_desc' => 'عند تمكين التسجيل سيكون المستخدم قادرا على تسجيل نفسه كمستخدم تطبيق. عند التسجيل يعطى لهم دور مستخدم افتراضي وحيد.',
     'reg_default_role' => 'دور المستخدم الأساسي بعد التسجيل',
-    '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' => 'يتم تجاهل الخيار أعلاه بينما يتم تفعيل مصادقة LDAP الخارجية أو SAML. حسابات المستخدم للأعضاء غير الحاليين سيتم إنشاؤها تلقائياً إذا كانت المصادقة، مقابل النظام الخارجي المستخدم، ناجحة.',
+    'reg_email_confirmation' => 'تأكيد البريد الإلكتروني',
+    'reg_email_confirmation_toggle' => 'يتطلب تأكيد البريد الإلكتروني',
     'reg_confirm_email_desc' => 'إذا تم استخدام قيود للمجال سيصبح التأكيد عن طريق البريد الإلكتروني إلزامي وسيتم تجاهل القيمة أسفله.',
     'reg_confirm_restrict_domain' => 'تقييد التسجيل على مجال محدد',
-    '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_desc' => 'أدخل قائمة مفصولة بفواصل لنطاقات البريد الإلكتروني التي ترغب في تقييد التسجيل إليها. سيتم إرسال بريد إلكتروني للمستخدمين لتأكيد عنوانهم قبل السماح لهم بالتفاعل مع التطبيق. <br> لاحظ أن المستخدمين سيكونون قادرين على تغيير عناوين البريد الإلكتروني الخاصة بهم بعد التسجيل بنجاح.',
     'reg_confirm_restrict_domain_placeholder' => 'لم يتم اختيار أي قيود',
 
     // Maintenance settings
     'maint' => 'الصيانة',
     'maint_image_cleanup' => 'تنظيف الصور',
-    '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' => 'تجاهل الصور في المراجعات',
+    '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 من الصور المحتمل عدم استخدامها. تأكيد حذف الصور؟',
     'maint_image_cleanup_success' => 'تم إيجاد وحذف عدد :count من الصور المحتمل عدم استخدامها!',
     'maint_image_cleanup_nothing_found' => 'لم يتم حذف أي شيء لعدم وجود أي صور غير مسمتخدمة',
-    '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_send_test_email' => 'إرسال بريد إلكتروني تجريبي',
+    'maint_send_test_email_desc' => 'يرسل هذا بريدًا إلكترونيًا تجريبيًا إلى عنوان بريدك الإلكتروني المحدد في ملفك الشخصي.',
+    'maint_send_test_email_run' => 'إرسال بريد إليكتروني تجريبي',
+    'maint_send_test_email_success' => 'تم إرسال البريد الإلكتروني إلى:العنوان',
+    '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_desc' => 'هنا يمكنك استعادة العناصر التي تم حذفها أو اختيار إزالتها نهائيا من النظام. هذه القائمة غير مصفاة خلافاً لقوائم الأنشطة المماثلة في النظام حيث يتم تطبيق عوامل تصفية الأذونات.',
+    'recycle_bin_deleted_item' => 'عنصر محذوف',
+    'recycle_bin_deleted_parent' => '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' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'المحذوف: قُم بعد إجمالي العناصر من سلة المحذوفات.',
+    'recycle_bin_restore_notification' => 'المرتجع: قُم بعد إجمالي العناصر من سلة المحذوفات.',
+
+    // Audit Log
+    'audit' => 'سجل المراجعة',
+    'audit_desc' => 'يعرض هذا السجل قائمة بالأنشطة المتعقبة في النظام. هذه القائمة غير مصفاة خلافاً لقوائم الأنشطة المماثلة في النظام حيث يتم تطبيق عوامل تصفية الأذونات.',
+    'audit_event_filter' => 'تصفية الحدث',
+    'audit_event_filter_no_filter' => 'لا يوجد فلتر',
+    'audit_deleted_item' => 'عنصر محذوف',
+    'audit_deleted_item_name' => 'الاسم: كتابة الاسم',
+    'audit_table_user' => 'المستخدم',
+    'audit_table_event' => 'الحدث',
+    'audit_table_related' => 'العنصر أو التفاصيل ذات الصلة',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'تاريخ النشاط',
+    'audit_date_from' => 'نطاق التاريخ من',
+    'audit_date_to' => 'نطاق التاريخ إلى',
 
     // Role Settings
     'roles' => 'الأدوار',
@@ -88,7 +131,7 @@ return [
     'role_create_success' => 'تم إنشاء الدور بنجاح',
     'role_delete' => 'حذف الدور',
     'role_delete_confirm' => 'سيتم حذف الدور المسمى \':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_users_assigned' => 'هذا الدور له: عدد المستخدمين المعينين له. إذا كنت ترغب في ترحيل المستخدمين من هذا الدور ، فحدد دورًا جديدًا أدناه.',
     'role_delete_no_migration' => "لا تقم بترجيل المستخدمين",
     'role_delete_sure' => 'تأكيد حذف الدور؟',
     'role_delete_success' => 'تم حذف الدور بنجاح',
@@ -96,21 +139,24 @@ return [
     'role_details' => 'تفاصيل الدور',
     'role_name' => 'اسم الدور',
     'role_desc' => 'وصف مختصر للدور',
-    'role_external_auth_id' => 'External Authentication IDs',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    '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' => 'Manage page templates',
-    'role_access_api' => 'Access system API',
+    'role_manage_page_templates' => 'إدارة قوالب الصفحة',
+    'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API',
     'role_manage_settings' => 'إدارة إعدادات التطبيق',
-    'role_asset' => 'Asset Permissions',
-    '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_export_content' => 'Export content',
+    'role_asset' => 'أذونات الأصول',
+    'roles_system_warning' => 'اعلم أن الوصول إلى أي من الأذونات الثلاثة المذكورة أعلاه يمكن أن يسمح للمستخدم بتغيير امتيازاته الخاصة أو امتيازات الآخرين في النظام. قم بتعيين الأدوار مع هذه الأذونات فقط للمستخدمين الموثوق بهم.',
+    'role_asset_desc' => 'تتحكم هذه الأذونات في الوصول الافتراضي إلى الأصول داخل النظام. ستتجاوز الأذونات الخاصة بالكتب والفصول والصفحات هذه الأذونات.',
+    'role_asset_admins' => 'يُمنح المسؤولين حق الوصول تلقائيًا إلى جميع المحتويات ولكن هذه الخيارات قد تعرض خيارات واجهة المستخدم أو تخفيها.',
     'role_all' => 'الكل',
-    'role_own' => 'Own',
-    'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
+    'role_own' => 'ما يخص',
+    'role_controlled_by_asset' => 'يتحكم فيها الأصول التي يتم رفعها إلى',
     'role_save' => 'حفظ الدور',
     'role_update_success' => 'تم تحديث الدور بنجاح',
     'role_users' => 'مستخدمون داخل هذا الدور',
@@ -121,63 +167,71 @@ return [
     'user_profile' => 'ملف المستخدم',
     'users_add_new' => 'إضافة مستخدم جديد',
     'users_search' => 'بحث عن مستخدم',
-    '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' => 'أحدث نشاط',
+    'users_details' => 'بيانات المستخدم',
+    'users_details_desc' => 'قم بتعيين اسم عرض وعنوان بريد إلكتروني لهذا المستخدم. سيتم استخدام عنوان البريد الإلكتروني لتسجيل الدخول إلى التطبيق.',
+    'users_details_desc_no_email' => 'قم بتعيين اسم عرض لهذا المستخدم حتى يتمكن الآخرون من التعرف عليه.',
     '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_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_role_desc' => 'حدد الأدوار التي سيتم تعيين هذا المستخدم لها. إذا تم تعيين مستخدم لأدوار متعددة ، فسيتم تكديس الأذونات من هذه الأدوار وسيتلقى كل قدرات الأدوار المعينة.',
+    'users_password' => 'كلمة مرور المستخدم',
+    'users_password_desc' => 'قم بتعيين كلمة مرور مستخدمة لتسجيل الدخول إلى التطبيق. يجب ألا يقل طول هذه الكلمة عن 6 أحرف.',
+    '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_delete_success' => 'تم حذف المستخدم بنجاح',
+    '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_edit_profile' => 'تعديل الملف',
     'users_edit_success' => 'تم تحديث المستخدم بنجاح',
     'users_avatar' => 'صورة المستخدم',
     'users_avatar_desc' => 'يجب أن تكون الصورة مربعة ومقاربة لحجم 256 بكسل',
     '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' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not previously authorized access. Revoke access from your profile settings on the connected social account.',
     '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' => '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',
-    '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' => 'تم إنشاء رمز :الوقت الزمني',
+    'user_api_token_updated' => 'تم تحديث الرمز :الوقت الزمني',
+    'user_api_token_delete' => 'حذف الرمز',
+    'user_api_token_delete_warning' => 'سيؤدي هذا إلى حذف رمز API المُشار إليه بالكامل باسم \'اسم الرمز\' من النظام.',
+    'user_api_token_delete_confirm' => 'هل أنت متأكد من أنك تريد حذف رمز API؟',
+    'user_api_token_delete_success' => 'تم حذف رمز الـ API بنجاح',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 7d6d13e817713fed9832856a4bc57458a054edf0..d4d3aaf26b1f2f4a658c10ea1de911c405549dca 100644 (file)
@@ -14,7 +14,8 @@ return [
     'alpha'                => 'يجب أن يقتصر :attribute على الحروف فقط.',
     'alpha_dash'           => 'يجب أن يقتصر :attribute على حروف أو أرقام أو شرطات فقط.',
     'alpha_num'            => 'يجب أن يقتصر :attribute على الحروف والأرقام فقط.',
-    'array'                => 'The :attribute must be an array.',
+    'array'                => 'يجب أن تكون السمة مصفوفة.',
+    'backup_codes'         => 'The provided code is not valid or has already been used.',
     'before'               => 'يجب أن يكون التاريخ :attribute قبل :date.',
     'between'              => [
         'numeric' => 'يجب أن يكون :attribute بين :min و :max.',
@@ -22,7 +23,7 @@ return [
         'string'  => 'يجب أن يكون :attribute بين :min و :max حرف / حروف.',
         'array'   => 'يجب أن يكون :attribute بين :min و :max عنصر / عناصر.',
     ],
-    'boolean'              => 'The :attribute field must be true or false.',
+    'boolean'              => 'يجب أن يحتمل حقل السمة الصحة أو الخطأ.',
     'confirmed'            => ':attribute غير مطابق.',
     'date'                 => ':attribute ليس تاريخ صالح.',
     'date_format'          => ':attribute لا يطابق الصيغة :format.',
@@ -30,40 +31,40 @@ return [
     'digits'               => 'يجب أن يكون :attribute بعدد :digits خانات.',
     'digits_between'       => 'يجب أن يكون :attribute بعدد خانات بين :min و :max.',
     'email'                => 'يجب أن يكون :attribute عنوان بريد إلكتروني صالح.',
-    'ends_with' => 'The :attribute must end with one of the following: :values',
+    'ends_with' => 'يجب أن تنتهي السمة بأحد القيم التالية',
     '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' => 'يجب أن تكون السمة أكبر من: القيمة.',
+        'file'    => 'يجب أن تكون السمة أكبر من: القيمة كيلوبايت.',
+        'string'  => 'يجب أن تكون السمة أكبر من: أحرف القيمة.',
+        'array'   => 'يجب أن تحتوي السمة على أكثر من: عناصر القيمة.',
     ],
     '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' => 'يجب أن تكون السمة أكبر من أو تساوي: القيمة.',
+        'file'    => 'يجب أن تكون السمة أكبر من أو تساوي: القيمة كيلوبايت.',
+        'string'  => 'يجب أن تكون السمة أكبر من أو تساوي: أحرف القيمة.',
+        'array'   => 'يجب أن تحتوي السمة على: عناصر القيمة أو أكثر.',
     ],
     'exists'               => ':attribute المحدد غير صالح.',
     'image'                => 'يجب أن يكون :attribute صورة.',
-    'image_extension'      => 'The :attribute must have a valid & supported image extension.',
+    'image_extension'      => 'يجب أن تحتوي السمة على امتداد صورة صالح ومدعوم.',
     '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'                 => 'يجب أن تكون السمة: عنوان IPv4 صالحًا.',
+    'ipv6'                 => 'يجب أن تكون السمة: عنوان IPv6 صالحًا.',
+    'json'                 => 'يجب أن تكون السمة: سلسلة من نوع جسون 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' => 'يجب أن تكون السمة أقل من: القيمة.',
+        'file'    => 'يجب أن تكون السمة أقل من: القيمة كيلوبايت.',
+        'string'  => 'يجب أن تكون السمة أقل من: أحرف القيمة.',
+        'array'   => 'يجب أن تحتوي السمة على أقل من: عناصر القيمة.',
     ],
     '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' => 'يجب أن تكون السمة أقل من أو تساوي: القيمة.',
+        'file'    => 'يجب أن تكون السمة أقل من أو تساوي: القيمة كيلوبايت.',
+        'string'  => 'يجب أن تكون السمة أقل من أو تساوي: أحرف القيمة.',
+        'array'   => 'يجب ألا تحتوي السمة على أكثر من: عناصر القيمة.',
     ],
     'max'                  => [
         'numeric' => 'يجب ألا يكون :attribute أكبر من :max.',
@@ -78,9 +79,8 @@ return [
         'string'  => 'يجب أن يكون :attribute على الأقل :min حرف / حروف.',
         'array'   => 'يجب أن يحتوي :attribute على :min عنصر / عناصر كحد أدنى.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
     'not_in'               => ':attribute المحدد غير صالح.',
-    'not_regex'            => 'The :attribute format is invalid.',
+    'not_regex'            => 'صيغة السمة: غير صالحة.',
     'numeric'              => 'يجب أن يكون :attribute رقم.',
     'regex'                => 'صيغة :attribute غير صالحة.',
     'required'             => 'حقل :attribute مطلوب.',
@@ -90,17 +90,19 @@ return [
     'required_without'     => 'حقل :attribute مطلوب عندما تكون :values غير موجودة.',
     'required_without_all' => 'حقل :attribute مطلوب عندما لا يكون أي من :values موجودة.',
     'same'                 => 'يجب تطابق :attribute مع :other.',
+    'safe_url'             => 'قد لايكون الرابط المتوفر آمنا.',
     'size'                 => [
         'numeric' => 'يجب أن يكون :attribute بحجم :size.',
         'file'    => 'يجب أن يكون :attribute بحجم :size كيلو بايت.',
         'string'  => 'يجب أن يكون :attribute بعدد :size حرف / حروف.',
         'array'   => 'يجب أن يحتوي :attribute على :size عنصر / عناصر.',
     ],
-    'string'               => 'The :attribute must be a string.',
+    'string'               => 'يجب أن تكون السمة: سلسلة.',
     'timezone'             => 'يجب أن تكون :attribute منطقة صالحة.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     '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' => [
diff --git a/resources/lang/bg/activities.php b/resources/lang/bg/activities.php
new file mode 100644 (file)
index 0000000..15715ba
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    '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'              => 'създадена страница',
+    'chapter_create_notification' => 'Главата беше успешно създадена',
+    'chapter_update'              => 'обновена глава',
+    'chapter_update_notification' => 'Главата беше успешно обновена',
+    'chapter_delete'              => 'изтрита глава',
+    'chapter_delete_notification' => 'Главата беше успешно изтрита',
+    'chapter_move'                => 'преместена глава',
+
+    // Books
+    'book_create'                 => 'създадена книга',
+    'book_create_notification'    => 'Книгата беше успешно създадена',
+    'book_update'                 => 'обновена книга',
+    'book_update_notification'    => 'Книгата беше успешно обновена',
+    'book_delete'                 => 'изтрита книга',
+    'book_delete_notification'    => 'Книгата беше успешно изтрита',
+    'book_sort'                   => 'сортирана книга',
+    'book_sort_notification'      => 'Книгата беше успешно преподредена',
+
+    // Bookshelves
+    'bookshelf_create'            => 'създаден рафт',
+    'bookshelf_create_notification'    => 'Рафтът беше успешно създаден',
+    'bookshelf_update'                 => 'обновен рафт',
+    'bookshelf_update_notification'    => 'Рафтът беше успешно обновен',
+    'bookshelf_delete'                 => 'изтрит рафт',
+    'bookshelf_delete_notification'    => 'Рафтът беше успешно изтрит',
+
+    // 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',
+
+    // Other
+    'commented_on'                => 'коментирано на',
+    'permissions_update'          => 'updated permissions',
+];
diff --git a/resources/lang/bg/auth.php b/resources/lang/bg/auth.php
new file mode 100644 (file)
index 0000000..d04796c
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Въведените удостоверителни данни не съвпадат с нашите записи.',
+    'throttle' => 'Твърде много опити за влизане. Опитайте пак след :seconds секунди.',
+
+    // Login & Register
+    'sign_up' => 'Регистриране',
+    'log_in' => 'Влизане',
+    'log_in_with' => 'Влизане с :socialDriver',
+    'sign_up_with' => 'Регистриране с :socialDriver',
+    'logout' => 'Изход',
+
+    'name' => 'Име',
+    'username' => 'Потребител',
+    'email' => 'Имейл',
+    'password' => 'Парола',
+    'password_confirm' => 'Потвърди паролата',
+    'password_hint' => 'Трябва да бъде поне 7 символа',
+    '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_send_instructions' => 'Въведете емейла си и ще ви бъде изпратен емейл с линк за нулиране на паролата.',
+    'reset_password_send_button' => 'Изпращане на линк за нулиране',
+    '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' => 'Нужно ви е потвърждение чрез емейл, но системата не успя да го изпрати. Моля свържете се с администратора, за да проверите дали вашият емейл адрес е конфигуриран правилно.',
+    'email_confirm_success' => 'Адресът на електронната ви поща е потвърден!',
+    'email_confirm_resent' => 'Беше изпратен имейл с потвърждение, Моля, проверете кутията си.',
+
+    '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' => 'Вие бяхте поканен да се присъедините към :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' => 'Паролата е потвърдена и вече имате достъп до :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.',
+];
\ No newline at end of file
diff --git a/resources/lang/bg/common.php b/resources/lang/bg/common.php
new file mode 100644 (file)
index 0000000..4b0f72a
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Отказ',
+    'confirm' => 'Потвърди',
+    'back' => 'Назад',
+    'save' => 'Запази',
+    'continue' => 'Продължи',
+    'select' => 'Избери',
+    'toggle_all' => 'Избери всички',
+    'more' => 'Повече',
+
+    // Form Labels
+    'name' => 'Име',
+    'description' => 'Описание',
+    'role' => 'Роля',
+    'cover_image' => 'Основно изображение',
+    'cover_image_description' => 'Картината трябва да е приблизително 440х250 пиксела.',
+    
+    // Actions
+    'actions' => 'Действия',
+    'view' => 'Преглед',
+    'view_all' => 'Преглед на всички',
+    'create' => 'Създай',
+    'update' => 'Обновяване',
+    'edit' => 'Редактиране',
+    'sort' => 'Сортиране',
+    'move' => 'Преместване',
+    'copy' => 'Копирай',
+    'reply' => 'Отговори',
+    'delete' => 'Изтрий',
+    'delete_confirm' => 'Потвърдете изтриването',
+    'search' => 'Търси',
+    'search_clear' => 'Изчисти търсенето',
+    'reset' => 'Нулирай',
+    'remove' => 'Премахване',
+    'add' => 'Добави',
+    'configure' => 'Configure',
+    'fullscreen' => 'Пълен екран',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+
+    // Sort Options
+    'sort_options' => 'Опции за сортиране',
+    'sort_direction_toggle' => 'Активирай сортиране',
+    'sort_ascending' => 'Сортирай възходящо',
+    'sort_descending' => 'Низходящо сортиране',
+    'sort_name' => 'Име',
+    'sort_default' => 'Default',
+    'sort_created_at' => 'Дата на създаване',
+    'sort_updated_at' => 'Дата на обновяване',
+
+    // Misc
+    'deleted_user' => 'Изтриване на потребител',
+    'no_activity' => 'Няма активност за показване',
+    'no_items' => 'Няма налични артикули',
+    'back_to_top' => 'Върнете се в началото',
+    'skip_to_main_content' => 'Skip to main content',
+    'toggle_details' => 'Активирай детайли',
+    'toggle_thumbnails' => 'Активирай миниатюри',
+    'details' => 'Подробности',
+    'grid_view' => 'Табличен изглед',
+    'list_view' => 'Изглед списък',
+    'default' => 'Основен',
+    'breadcrumb' => 'Трасиране',
+
+    // Header
+    'header_menu_expand' => 'Expand Header Menu',
+    'profile_menu' => 'Профил меню',
+    'view_profile' => 'Разглеждане на профил',
+    'edit_profile' => 'Редактиране на профила',
+    'dark_mode' => 'Тъмен режим',
+    'light_mode' => 'Светъл режим',
+
+    // Layout tabs
+    'tab_info' => 'Информация',
+    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_content' => 'Съдържание',
+    'tab_content_label' => 'Tab: Show Primary Content',
+
+    // Email Content
+    'email_action_help' => 'Ако имате проблеми с бутона ":actionText" по-горе, копирайте и поставете URL адреса по-долу в уеб браузъра си:',
+    'email_rights' => 'Всички права запазени',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Лични данни',
+    'terms_of_service' => 'Общи условия',
+];
diff --git a/resources/lang/bg/components.php b/resources/lang/bg/components.php
new file mode 100644 (file)
index 0000000..4be89bf
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    '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' => 'Редактиране на кода',
+    'code_language' => 'Език на кода',
+    'code_content' => 'Съдържание на кода',
+    'code_session_history' => 'Session History',
+    'code_save' => 'Запази кода',
+];
diff --git a/resources/lang/bg/entities.php b/resources/lang/bg/entities.php
new file mode 100644 (file)
index 0000000..4880ad2
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    '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 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' => 'Markdown File',
+
+    // Permissions and restrictions
+    'permissions' => 'Права',
+    'permissions_intro' => 'Веднъж добавени, тези права ще вземат приоритет над всички други установени права.',
+    'permissions_enable' => 'Разреши уникални права',
+    'permissions_save' => 'Запази права',
+    'permissions_owner' => 'Собственик',
+
+    // Search
+    'search_results' => 'Резултати от търсенето',
+    'search_total_results_found' => ':count резултати намерени|:count общо намерени резултати',
+    'search_clear' => 'Изчисти търсенето',
+    'search_no_pages' => 'Няма страници отговарящи на търсенето',
+    'search_for_term' => 'Търси :term',
+    'search_more' => 'Още резултати',
+    'search_advanced' => 'Подробно търсене',
+    'search_terms' => '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' => 'Рафт',
+    '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' => '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' => 'Настройките за достъп на рафта с книги бяха копирани върху :count books',
+
+    // Books
+    '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' => 'Запази новата подредба',
+
+    // Chapters
+    'chapter' => 'Глава',
+    'chapters' => 'Глави',
+    'x_chapters' => ':count Глава|:count Глави',
+    'chapters_popular' => 'Популярни глави',
+    'chapters_new' => 'Нова глава',
+    '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_confirm' => 'Сигурни ли сте, че искате да изтриете тази глава?',
+    'chapters_edit' => 'Редактирай глава',
+    'chapters_edit_named' => 'Актуализирай глава :chapterName',
+    'chapters_save' => 'Запази глава',
+    'chapters_move' => 'Премести глава',
+    'chapters_move_named' => 'Премести глава :chapterName',
+    'chapter_move_success' => 'Главата беше преместена в :bookName',
+    'chapters_permissions' => 'Настойки за достъп на главата',
+    'chapters_empty' => 'Няма създадени страници в тази глава.',
+    'chapters_permissions_active' => 'Настройките за достъп до глава са активни',
+    'chapters_permissions_success' => 'Настройките за достъп до главата бяха обновени',
+    'chapters_search_this' => 'Търси в тази глава',
+
+    // Pages
+    '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' => 'Restored from #:id; :summary',
+    'pages_revisions_created_by' => 'Създадено от',
+    'pages_revisions_date' => 'Дата на ревизията',
+    'pages_revisions_number' => '№',
+    '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_edit_active' => [
+        'start_a' => ':count потребителя започнаха да редактират настоящата страница',
+        'start_b' => ':userName в момента редактира тази страница',
+        'time_a' => 'от както страницата беше актуализирана',
+        'time_b' => 'в последните :minCount минути',
+        'message' => ':start :time. Внимавайте да не попречите на актуализацията на другия!',
+    ],
+    'pages_draft_discarded' => 'Черновата беше отхърлена, Редактора беше обновен с актуалното съдържание на страницата',
+    'pages_specific' => 'Определена страница',
+    'pages_is_template' => 'Шаблон на страницата',
+
+    // Editor Sidebar
+    'page_tags' => 'Тагове на страницата',
+    'chapter_tags' => 'Тагове на главата',
+    'book_tags' => 'Тагове на книгата',
+    'shelf_tags' => 'Тагове на рафта',
+    'tag' => 'Таг',
+    'tags' =>  'Тагове',
+    'tag_name' =>  'Име на таг',
+    'tag_value' => 'Съдържание на тага (Опционално)',
+    'tags_explain' => "Добавете няколко тага за да категоризирате по добре вашето съдържание. \n Може да добавите съдържание на таговете за по-подробна организация.",
+    'tags_add' => 'Добави друг таг',
+    'tags_remove' => 'Премахни този таг',
+    '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' => 'Url на сайт или файл',
+    'attach' => 'Прикачване',
+    'attachments_insert_link' => 'Add Attachment Link to Page',
+    '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' => 'Потребител от :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' => 'Коментирай',
+    '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' => 'Наистина ли искате да изтриете тази версия?',
+    'revision_restore_confirm' => 'Сигурни ли сте, че искате да изтриете тази версия? Настоящата страница ще бъде заместена.',
+    'revision_delete_success' => 'Версията беше изтрита',
+    'revision_cannot_delete_latest' => 'Не може да изтриете последната версия.'
+];
diff --git a/resources/lang/bg/errors.php b/resources/lang/bg/errors.php
new file mode 100644 (file)
index 0000000..ea6d497
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Нямате права за достъп до избраната страница.',
+    'permissionJson' => 'Нямате права да извършите тази операция.',
+
+    // Auth
+    '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' => 'LDAP PHP не беше инсталирана',
+    '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 не беше успешно, системата не успя да оторизира потребителя',
+    'social_no_action_defined' => 'Действието не беше дефинирано',
+    'social_login_bad_response' => "Възникна грешка по време на :socialAccount login: \n:error",
+    'social_account_in_use' => 'Този :socialAccount вече е използван. Опитайте се да влезете чрез опцията за :socialAccount.',
+    'social_account_email_in_use' => 'Този емейл адрес вече е бил използван. Ако вече имате профил, може да го свържете чрез :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 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.',
+
+    // 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.',
+
+    // Attachments
+    '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',
+
+    // 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',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'You cannot delete the only admin',
+    'users_cannot_delete_guest' => 'You cannot delete the guest user',
+
+    // 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.',
+
+    // Comments
+    'comment_list' => 'An error occurred while fetching the comments.',
+    'cannot_add_comment_to_draft' => 'Не може да добавяте коментари към чернова.',
+    'comment_add' => 'Възникна грешка при актуализиране/добавяне на коментар.',
+    'comment_delete' => 'Възникна грешка при изтриването на коментара.',
+    'empty_comment' => 'Не може да добавите празен коментар.',
+
+    // Error pages
+    '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 не е достъпно в момента',
+    'back_soon' => 'Ще се върне обратно онлайн скоро.',
+
+    // API errors
+    'api_no_authorization_found' => 'Но беше намерен код за достъп в заявката',
+    'api_bad_authorization_format' => 'В заявката имаше код за достъп, но формата изглежда е неправилен',
+    'api_user_token_not_found' => 'Няма открит API код, който да отговоря на предоставения такъв',
+    'api_incorrect_token_secret' => 'Секретния код, който беше предоставен за достъп до API-а е неправилен',
+    'api_user_no_api_permission' => 'Собственика на АPI кода няма право да прави API заявки',
+    'api_user_token_expired' => 'Кода за достъп, който беше използван, вече не е валиден',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Беше върната грешка, когато се изпрати тестовият емейл:',
+
+];
diff --git a/resources/lang/bg/pagination.php b/resources/lang/bg/pagination.php
new file mode 100644 (file)
index 0000000..7844171
--- /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; Предишна',
+    'next'     => 'Следваща &raquo;',
+
+];
diff --git a/resources/lang/bg/passwords.php b/resources/lang/bg/passwords.php
new file mode 100644 (file)
index 0000000..871fdee
--- /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' => 'Паролите трябва да имат поне 8 символа и да съвпадат с потвърждението.',
+    'user' => "Не можем да намерим потребител с този имейл адрес.",
+    'token' => 'Кодът за зануляване на паролата е невалиден за този емейл адрес.',
+    'sent' => 'Пратихме връзка за нулиране на паролата до имейла ви!',
+    'reset' => 'Вашата парола е нулирана!',
+
+];
diff --git a/resources/lang/bg/settings.php b/resources/lang/bg/settings.php
new file mode 100644 (file)
index 0000000..5c1e1c9
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Настройки',
+    'settings_save' => 'Запази настройките',
+    'settings_save_success' => 'Настройките са записани',
+
+    // App Settings
+    'app_customization' => 'Персонализиране',
+    'app_features_security' => 'Екстри и Сигурност',
+    'app_name' => 'Име на приложението',
+    'app_name_desc' => 'Това име е включено във всяка шапка и във всеки имейл изпратен от системата.',
+    'app_name_header' => 'Покажи името в шапката',
+    'app_public_access' => 'Публичен достъп',
+    'app_public_access_desc' => 'Активирането на тази настройка, ще позволи на гости, които не са влезли в системта, да имат достъп до съдържанието на вашето приложение.',
+    'app_public_access_desc_guest' => 'Достъпа на гостите може да бъде контролиран от "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' => 'Това изображение трябва да е с 43px височина. <br> Големите изображения ще бъдат намалени.',
+    'app_primary_color' => 'Основен цвят на приложението',
+    'app_primary_color_desc' => 'Изберете основния цвят на приложението, включително на банера, бутоните и линковете.',
+    'app_homepage' => 'Application 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' => 'Link Label',
+    '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.',
+
+    // 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',
+
+    // 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',
+
+    // 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' => 'Изпрати тестов имейл',
+    '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_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' => 'Изтрит предмет',
+    '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',
+    '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_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.',
+
+    // 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' => 'Без филтър',
+    'audit_deleted_item' => 'Изтрит предмет',
+    'audit_deleted_item_name' => 'Име: :name',
+    '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' => 'Време до',
+
+    // Role Settings
+    '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' => 'Управление на потребители',
+    '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' => 'Export content',
+    'role_asset' => 'Настройки за достъп до активи',
+    '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' => 'Запази ролята',
+    'role_update_success' => 'Ролята беше успешно актуализирана',
+    'role_users' => 'Потребители в тази роля',
+    'role_users_none' => 'В момента няма потребители, назначени за тази роля',
+
+    // Users
+    '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_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' => 'Изтрий потребител',
+    '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' => 'Няма избрани потребители',
+    '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_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',
+    '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',
+
+    //! 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',
+        '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/bg/validation.php b/resources/lang/bg/validation.php
new file mode 100644 (file)
index 0000000..0a5b81d
--- /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 трябва да бъде одобрен.',
+    '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' => ':attribute трябва да е между :min и :max.',
+        'file'    => ':attribute трябва да е между :min и :max килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде между :min и :max символа.',
+        'array'   => 'The :attribute must have between :min and :max items.',
+    ],
+    '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' => ':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'    => 'Големината на :attribute трябва да бъде по-голямо или равно на :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-голямо или равно на :value символа.',
+        'array'   => 'The :attribute must have :value items or more.',
+    ],
+    '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' => ':attribute трябва да бъде по-малко от :value.',
+        'file'    => 'Големината на :attribute трябва да бъде по-малко от :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-малко от :value символа.',
+        'array'   => 'The :attribute must have less than :value items.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute трябва да бъде по-малко или равно на :value.',
+        'file'    => 'Големината на :attribute трябва да бъде по-малко или равно на :value килобайта.',
+        'string'  => 'Дължината на :attribute трябва да бъде по-малко или равно на :value символа.',
+        'array'   => 'The :attribute must not have more than :value items.',
+    ],
+    'max'                  => [
+        '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.',
+    '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.',
+    ],
+    '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.',
+    '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.',
+    ],
+    '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.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Password confirmation required',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
diff --git a/resources/lang/bs/activities.php b/resources/lang/bs/activities.php
new file mode 100644 (file)
index 0000000..9136e65
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'je kreirao/la stranicu',
+    'page_create_notification'    => 'Stranica Uspješno Kreirana',
+    'page_update'                 => 'je ažurirao/la stranicu',
+    'page_update_notification'    => 'Stranica Uspješno Ažurirana',
+    'page_delete'                 => 'je izbrisao/la stranicu',
+    'page_delete_notification'    => 'Stranica Uspješno Izbrisana',
+    'page_restore'                => 'je vratio/la stranicu',
+    'page_restore_notification'   => 'Stranica Uspješno Vraćena',
+    'page_move'                   => 'je premjestio/la stranicu',
+
+    // Chapters
+    'chapter_create'              => 'je kreirao/la poglavlje',
+    'chapter_create_notification' => 'Poglavlje Uspješno Kreirano',
+    'chapter_update'              => 'je ažurirao/la poglavlje',
+    'chapter_update_notification' => 'Poglavlje Uspješno Ažurirano',
+    'chapter_delete'              => 'je izbrisao/la poglavlje',
+    'chapter_delete_notification' => 'Poglavlje Uspješno Izbrisano',
+    'chapter_move'                => 'je premjestio/la poglavlje',
+
+    // Books
+    'book_create'                 => 'je kreirao/la knjigu',
+    'book_create_notification'    => 'Knjiga Uspješno Kreirana',
+    'book_update'                 => 'je ažurirao/la knjigu',
+    'book_update_notification'    => 'Knjiga Uspješno Ažurirana',
+    'book_delete'                 => 'je izbrisao/la knjigu',
+    'book_delete_notification'    => 'Knjiga Uspješno Izbrisana',
+    'book_sort'                   => 'je sortirao/la knjigu',
+    'book_sort_notification'      => 'Knjiga Uspješno Ponovno Sortirana',
+
+    // Bookshelves
+    'bookshelf_create'            => 'je kreirao/la Policu za knjige',
+    'bookshelf_create_notification'    => 'Polica za knjige Uspješno Kreirana',
+    'bookshelf_update'                 => 'je ažurirao/la policu za knjige',
+    'bookshelf_update_notification'    => 'Polica za knjige Uspješno Ažurirana',
+    'bookshelf_delete'                 => 'je izbrisao/la policu za knjige',
+    'bookshelf_delete_notification'    => 'Polica za knjige Uspješno Izbrisana',
+
+    // 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',
+
+    // Other
+    'commented_on'                => 'je komentarisao/la na',
+    'permissions_update'          => 'je ažurirao/la dozvole',
+];
diff --git a/resources/lang/bs/auth.php b/resources/lang/bs/auth.php
new file mode 100644 (file)
index 0000000..a5926fa
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Ovi pristupni podaci se ne slažu sa našom evidencijom.',
+    'throttle' => 'Preveliki broj pokušaja prijave. Molimo vas da pokušate ponovo za :seconds sekundi.',
+
+    // Login & Register
+    'sign_up' => 'Registruj se',
+    'log_in' => 'Prijavi se',
+    'log_in_with' => 'Prijavi se sa :socialDriver',
+    'sign_up_with' => 'Registruj se sa :socialDriver',
+    'logout' => 'Odjavi se',
+
+    'name' => 'Ime',
+    'username' => 'Korisničko ime',
+    'email' => 'E-mail',
+    'password' => 'Lozinka',
+    'password_confirm' => 'Potvrdi lozinku',
+    'password_hint' => 'Mora imati više od 7 karaktera',
+    'forgot_password' => 'Zaboravljena lozinka?',
+    'remember_me' => 'Zapamti me',
+    'ldap_email_hint' => 'Unesite e-mail koji će se koristiti za ovaj račun.',
+    'create_account' => 'Napravi račun',
+    'already_have_account' => 'Već imate račun?',
+    'dont_have_account' => 'Nemate korisnički račun?',
+    'social_login' => 'Prijava preko društvene mreže',
+    'social_registration' => 'Registracija pomoću društvene mreže',
+    'social_registration_text' => 'Registruj i prijavi se koristeći drugi servis.',
+
+    'register_thanks' => 'Hvala na registraciji!',
+    'register_confirm' => 'Provjerite vašu e-mail adresu i pritisnite dugme za potvrdu da bi dobili pristup :appName.',
+    'registrations_disabled' => 'Registracije su trenutno onemogućene',
+    '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.',
+    'reset_password_send_button' => 'Pošalji link za promjenu',
+    'reset_password_sent' => 'Link za promjenu lozinke će biti poslan na :email ako ta adresa postoji u sistemu.',
+    'reset_password_success' => 'Vaša lozinka je uspješno promijenjena.',
+    'email_reset_subject' => 'Resetujte vašu lozinku od :appName',
+    '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_resent' => 'E-mail za potvrdu je ponovno poslan. Provjerite vaš e-mail.',
+
+    'email_not_confirmed' => 'E-mail adresa nije potvrđena',
+    'email_not_confirmed_text' => 'Vaša e-mail adresa nije još potvrđena.',
+    'email_not_confirmed_click_link' => 'Kliknite na link u e-mailu koji vam je poslan nakon što ste se registrovali.',
+    'email_not_confirmed_resend' => 'Ako ne možete naći e-mail možete ponovno poslati e-mail za potvrdu tako što ćete ispuniti formu ispod.',
+    'email_not_confirmed_resend_button' => 'Ponovno pošalji e-mail za potvrdu',
+
+    // User Invite
+    'user_invite_email_subject' => 'Pozvani ste da se pridružite :appName!',
+    'user_invite_email_greeting' => 'Račun je napravljen za vas na :appName.',
+    'user_invite_email_text' => 'Pritisnite dugme ispod da niste postavili lozinku vašeg računa i tako dobili pristup:',
+    'user_invite_email_action' => 'Postavi lozinku računa',
+    '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!',
+
+    // 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.',
+];
\ No newline at end of file
diff --git a/resources/lang/bs/common.php b/resources/lang/bs/common.php
new file mode 100644 (file)
index 0000000..17ec0b7
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Otkaži',
+    'confirm' => 'Potvrdi',
+    'back' => 'Nazad',
+    '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' => 'Ova slika treba biti približno 440x250px.',
+    
+    // Actions
+    'actions' => 'Akcije',
+    'view' => 'Prikaz',
+    'view_all' => 'Prikaži sve',
+    'create' => 'Kreiraj',
+    'update' => 'Ažuriraj',
+    'edit' => 'Uredi',
+    'sort' => 'Sortiraj',
+    'move' => 'Pomjeri',
+    'copy' => 'Kopiraj',
+    'reply' => 'Odgovori',
+    'delete' => 'Izbriši',
+    'delete_confirm' => 'Potvrdi brisanje',
+    'search' => 'Traži',
+    'search_clear' => 'Očisti pretragu',
+    'reset' => 'Resetuj',
+    'remove' => 'Ukloni',
+    'add' => 'Dodaj',
+    'configure' => 'Configure',
+    'fullscreen' => 'Prikaz preko čitavog ekrana',
+    'favourite' => 'Favorit',
+    'unfavourite' => 'Ukloni favorit',
+    'next' => 'Sljedeće',
+    'previous' => 'Prethodno',
+
+    // Sort Options
+    'sort_options' => 'Opcije sortiranja',
+    'sort_direction_toggle' => 'Prebacivanje smjera sortiranja',
+    'sort_ascending' => 'Sortiraj uzlazno',
+    'sort_descending' => 'Sortiraj silazno',
+    'sort_name' => 'Ime',
+    'sort_default' => 'Početne postavke',
+    'sort_created_at' => 'Datum kreiranja',
+    'sort_updated_at' => 'Datum ažuriranja',
+
+    // Misc
+    'deleted_user' => 'Obrisani korisnik',
+    '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',
+    'grid_view' => 'Prikaz rešetke',
+    'list_view' => 'Prikaz liste',
+    'default' => 'Početne postavke',
+    'breadcrumb' => 'Navigacijske stavke',
+
+    // Header
+    'header_menu_expand' => 'Otvori meni u zaglavlju',
+    'profile_menu' => 'Meni profila',
+    'view_profile' => 'Pogledaj profil',
+    'edit_profile' => 'Izmjeni profil',
+    'dark_mode' => 'Tamni način rada',
+    'light_mode' => 'Svijetli način rada',
+
+    // Layout tabs
+    'tab_info' => 'Informacije',
+    'tab_info_label' => 'Kartica: Prikaži dodatnu informaciju',
+    'tab_content' => 'Sadržaj',
+    '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č:',
+    'email_rights' => 'Sva prava pridržana',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Pravila o privatnosti',
+    'terms_of_service' => 'Uslovi korištenja',
+];
diff --git a/resources/lang/bs/components.php b/resources/lang/bs/components.php
new file mode 100644 (file)
index 0000000..d40a95a
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Biraj sliku',
+    'image_all' => 'Sve',
+    'image_all_title' => 'Pogledaj sve slike',
+    'image_book_title' => 'Pogledaj slike prenesene u ovu knjigu',
+    'image_page_title' => 'Pogledaj slike prenesene na ovu stranicu',
+    'image_search_hint' => 'Traži po nazivu slike',
+    'image_uploaded' => 'Preneseno :uploadedDate',
+    'image_load_more' => 'Učitaj još',
+    'image_image_name' => 'Naziv slike',
+    'image_delete_used' => 'Ova slika se koristi na stranicama prikazanim ispod.',
+    'image_delete_confirm_text' => 'Jeste li sigurni da želite obrisati ovu sliku?',
+    'image_select_image' => 'Odaberi sliku',
+    'image_dropzone' => 'Ostavi slike ili pritisnite ovdje da ih prenesete',
+    'images_deleted' => 'Slike su izbrisane',
+    'image_preview' => 'Pregled Slike',
+    'image_upload_success' => 'Slika uspješno učitana',
+    'image_update_success' => 'Detalji slike uspješno ažurirani',
+    'image_delete_success' => 'Slika uspješno izbrisana',
+    'image_upload_remove' => 'Ukloni',
+
+    // Code Editor
+    'code_editor' => 'Uredi Kod',
+    'code_language' => 'Jezik koda',
+    'code_content' => 'Sadržaj Koda',
+    'code_session_history' => 'Historija Sesije',
+    'code_save' => 'Snimi Kod',
+];
diff --git a/resources/lang/bs/entities.php b/resources/lang/bs/entities.php
new file mode 100644 (file)
index 0000000..52924d9
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Nedavno napravljen',
+    'recently_created_pages' => 'Nedavno napravljene stranice',
+    'recently_updated_pages' => 'Nedavno ažurirane stranice',
+    'recently_created_chapters' => 'Nedavno napravljena poglavlja',
+    'recently_created_books' => 'Nedavno napravljene knjige',
+    'recently_created_shelves' => 'Nedavno napravljene police',
+    'recently_update' => 'Nedavno ažurirana',
+    'recently_viewed' => 'Nedavno pogledana',
+    'recent_activity' => 'Nedavna aktivnost',
+    'create_now' => 'Napravi jednu sada',
+    'revisions' => 'Promjene',
+    'meta_revision' => 'Promjena #:revisionCount',
+    'meta_created' => 'Napravljena :timeLength',
+    'meta_created_name' => 'Napravljena :timeLength od :user',
+    'meta_updated' => 'Ažurirana :timeLength',
+    'meta_updated_name' => 'Ažurirana :timeLength od :user',
+    'meta_owned_name' => 'Vlasnik je :user',
+    'entity_select' => 'Odaberi entitet',
+    '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',
+    'export' => 'Izvezi',
+    'export_html' => 'Sadržani web fajl',
+    'export_pdf' => 'PDF fajl',
+    'export_text' => 'Plain Text fajl',
+    'export_md' => 'Markdown File',
+
+    // Permissions and restrictions
+    'permissions' => 'Dozvole',
+    'permissions_intro' => 'Jednom omogućene, ove dozvole imaju prednost nad dozvolama uloge.',
+    'permissions_enable' => 'Omogući prilagođena dopuštenja',
+    'permissions_save' => 'Snimi dozvole',
+    'permissions_owner' => 'Vlasnik',
+
+    // Search
+    'search_results' => 'Rezultati pretrage',
+    'search_total_results_found' => ':count rezultata je nađeno|:count ukupno rezultata je nađeno',
+    'search_clear' => 'Očisti pretragu',
+    'search_no_pages' => 'Nijedna stranica nije nađena',
+    'search_for_term' => 'Traži :term',
+    'search_more' => 'Više rezultata',
+    'search_advanced' => 'Napredna pretraga',
+    'search_terms' => 'Pojmovi za pretragu',
+    'search_content_type' => 'Vrsta sadržaja',
+    'search_exact_matches' => 'Tačna podudaranja',
+    'search_tags' => 'Pretraga oznaka',
+    'search_options' => 'Opcije',
+    'search_viewed_by_me' => 'Ja sam pogledao/la',
+    'search_not_viewed_by_me' => 'Nisam pogledao/la',
+    'search_permissions_set' => 'Dozvole',
+    'search_created_by_me' => 'Ja sam napravio/la',
+    'search_updated_by_me' => 'Ja sam ažurirao/la',
+    'search_owned_by_me' => 'Owned by me',
+    'search_date_options' => 'Opcije datuma',
+    'search_updated_before' => 'Ažurirano prije',
+    'search_updated_after' => 'Ažurirano nakon',
+    'search_created_before' => 'Kreirano prije',
+    'search_created_after' => 'Kreirano nakon',
+    'search_set_date' => 'Postavi datum',
+    'search_update' => 'Ažuriraj pretragu',
+
+    // Shelves
+    'shelf' => 'Polica',
+    'shelves' => 'Police',
+    'x_shelves' => ':count Polica|:count Police',
+    'shelves_long' => 'Police za knjige',
+    'shelves_empty' => 'Niti jedna polica nije kreirana',
+    'shelves_create' => 'Kreiraj novu policu',
+    'shelves_popular' => 'Popularne police',
+    'shelves_new' => 'Nove police',
+    'shelves_new_action' => 'Nova polica',
+    'shelves_popular_empty' => 'Najpopularnije police će se pojaviti ovdje.',
+    'shelves_new_empty' => 'Najnovije police će se pojaviti ovdje.',
+    'shelves_save' => 'Spremi policu',
+    'shelves_books' => 'Knjige na ovoj polici',
+    'shelves_add_books' => 'Dodaj knjige na ovu policu',
+    'shelves_drag_books' => 'Prenesi knjige ovdje da bi ih dodao/la na ovu policu',
+    'shelves_empty_contents' => 'Ova polica nema knjiga koje su postavljene na nju',
+    'shelves_edit_and_assign' => 'Uredi policu da bi dodao/la knjige',
+    'shelves_edit_named' => 'Uredi :name police za knjige',
+    'shelves_edit' => 'Uredi policu za knjige',
+    'shelves_delete' => 'Izbriši policu za knjige',
+    'shelves_delete_named' => 'Izbriši policu za knjige :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_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.',
+    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :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' => 'Ovo će izbrisati knjigu naziva \':bookName\'. Sve stranice i poglavlja će biti uklonjene.',
+    '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' => 'Naziv knjige',
+    'books_save' => 'Spremi knjigu',
+    'books_permissions' => 'Dozvole knjige',
+    'books_permissions_updated' => 'Dozvole knjige su ažurirane',
+    'books_empty_contents' => 'Za ovu knjigu nisu napravljene ni stranice ni poglavlja.',
+    'books_empty_create_page' => 'Napravi novu stranicu',
+    'books_empty_sort_current_book' => 'Sortiraj trenutnu knjigu',
+    'books_empty_add_chapter' => 'Dodaj poglavlje',
+    'books_permissions_active' => 'Dozvole za knjigu su aktivne',
+    'books_search_this' => 'Pretraži ovu knjigu',
+    'books_navigation' => 'Navigacija knjige',
+    'books_sort' => 'Sortiraj sadržaj knjige',
+    'books_sort_named' => 'Sortiraj knjigu :bookName',
+    'books_sort_name' => 'Sortiraj po imenu',
+    'books_sort_created' => 'Sortiraj po datumu kreiranja',
+    'books_sort_updated' => 'Sortiraj po datumu ažuriranja',
+    'books_sort_chapters_first' => 'Poglavlja prva',
+    'books_sort_chapters_last' => 'Poglavlja zadnja',
+    'books_sort_show_other' => 'Prikaži druge knjige',
+    'books_sort_save' => 'Spremi trenutni poredak',
+
+    // Chapters
+    'chapter' => 'Poglavlje',
+    'chapters' => 'Poglavlja',
+    'x_chapters' => ':count Poglavlje|:count Poglavlja',
+    'chapters_popular' => 'Popularna poglavlja',
+    'chapters_new' => 'Novo poglavlje',
+    'chapters_create' => 'Napravi novo poglavlje',
+    'chapters_delete' => 'Izbriši poglavlje',
+    'chapters_delete_named' => 'Izbriši poglavlje :chapterName',
+    'chapters_delete_explain' => 'Ovo će izbrisati poglavlje naziva \':chapterName\'. Sve stranice koje postoje u ovom poglavlju će također biti izbrisane.',
+    'chapters_delete_confirm' => 'Jeste li sigurni da želite izbrisati ovo 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_permissions' => 'Dozvole poglavlja',
+    'chapters_empty' => 'U ovom poglavlju trenutno nema stranica.',
+    'chapters_permissions_active' => 'Dozvole za poglavlje su aktivne',
+    'chapters_permissions_success' => 'Dozvole za poglavlje su ažurirane',
+    'chapters_search_this' => 'Pretražuj ovo poglavlje',
+
+    // Pages
+    'page' => 'Stranica',
+    'pages' => 'Stranice',
+    'x_pages' => ':count Stranica|:count Stranice',
+    'pages_popular' => 'Popularne stranice',
+    'pages_new' => 'Nova stranica',
+    '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',
+    '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' => 'Trenutna verzija',
+    'pages_revisions_preview' => 'Pregled',
+    'pages_revisions_restore' => 'Vrati',
+    'pages_revisions_none' => 'Ova stranica nema promjena',
+    'pages_copy_link' => 'Iskopiraj link',
+    'pages_edit_content_link' => 'Uredi sadržaj',
+    'pages_permissions_active' => 'Dozvole za stranicu su aktivne',
+    'pages_initial_revision' => 'Prvo izdavanje',
+    '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_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',
+        'time_a' => 'od kada je stranica posljednji put ažurirana',
+        'time_b' => 'u posljednjih :minCount minuta',
+        'message' => ':start :time. Pazite da jedni drugima ne prepišete promjene!',
+    ],
+    'pages_draft_discarded' => 'Skica je odbačena, uređivač je ažuriran sa trenutnim sadržajem stranice',
+    'pages_specific' => 'Specifična stranica',
+    'pages_is_template' => 'Predložak stranice',
+
+    // Editor Sidebar
+    'page_tags' => 'Oznake stranice',
+    'chapter_tags' => 'Oznake poglavlja',
+    'book_tags' => 'Oznake knjige',
+    'shelf_tags' => 'Oznake police',
+    'tag' => 'Oznaka',
+    'tags' =>  'Oznake',
+    'tag_name' =>  'Naziv oznake',
+    'tag_value' => 'Vrijednost oznake (nije obavezno)',
+    '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',
+    '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.',
+    'attachments_items' => 'Priložene stavke',
+    'attachments_upload' => 'Učitaj fajl',
+    'attachments_link' => 'Zakači link',
+    'attachments_set_link' => 'Postavi link',
+    'attachments_delete' => 'Jeste li sigurni da želite obrisati ovaj prilog?',
+    'attachments_dropzone' => 'Spustite fajlove ili pritisnite ovdje da priložite fajl',
+    'attachments_no_files' => 'Niti jedan fajl nije prenesen',
+    'attachments_explain_link' => 'Možete zakačiti link ako ne želite učitati fajl. To može biti link druge stranice ili link za fajl u oblaku.',
+    'attachments_link_name' => 'Naziv linka',
+    'attachment_link' => 'Link poveznice',
+    'attachments_link_url' => 'Link do fajla',
+    'attachments_link_url_hint' => 'Url stranice ili fajla',
+    'attach' => 'Zakači',
+    'attachments_insert_link' => 'Dodaj priloženi link na stranicu',
+    'attachments_edit_file' => 'Uredi fajl',
+    'attachments_edit_file_name' => 'Naziv fajla',
+    'attachments_edit_drop_upload' => 'Spusti fajlove ili pritisni ovdje da učitaš i prepišeš',
+    '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',
+
+    // 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',
+
+    // 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',
+
+    // 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.'
+];
diff --git a/resources/lang/bs/errors.php b/resources/lang/bs/errors.php
new file mode 100644 (file)
index 0000000..f369606
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Nemate ovlaštenje da pristupite ovoj stranici.',
+    'permissionJson' => 'Nemate ovlaštenje da izvršite tu akciju.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Korisnik sa e-mailom :email već postoji ali sa različitim podacima.',
+    'email_already_confirmed' => 'E-mail je već potvrđen, pokušajte se prijaviti.',
+    'email_confirmation_invalid' => 'Ovaj token za potvrdu nije ispravan ili je već iskorišten, molimo vas pokušajte se registrovati ponovno.',
+    'email_confirmation_expired' => 'Ovaj token za potvrdu je istekao, novi e-mail za potvrdu je poslan.',
+    'email_confirmation_awaiting' => 'E-mail adresa za račun koji se koristi mora biti potvrđena',
+    'ldap_fail_anonymous' => 'LDAP pristup nije uspio koristeći anonimno povezivanje',
+    'ldap_fail_authed' => 'LDAP pristup nije uspio koristeći date detalje lozinke i dn',
+    'ldap_extension_not_installed' => 'LDAP PHP ekstenzija nije instalirana',
+    'ldap_cannot_connect' => 'Nije se moguće povezati sa ldap serverom, incijalna konekcija nije uspjela',
+    'saml_already_logged_in' => 'Već prijavljeni',
+    'saml_user_not_registered' => 'Korisnik :user nije registrovan i automatska registracija je onemogućena',
+    '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',
+    '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.',
+    'social_account_email_in_use' => 'E-mail :email se već koristi. Ako već imate račun možete povezati vaš :socialAccount račun u postavkama profila.',
+    'social_account_existing' => 'Ovaj :socialAccount je već povezan sa vašim profilom.',
+    'social_account_already_used_existing' => 'Drugi korisnik već koristi ovaj :socialAccount.',
+    'social_account_not_used' => 'Ovaj :socialAccount nije povezan ni sa jednim korisnikom. Povežite ga u postavkama profila. ',
+    'social_account_register_instructions' => 'Ako još uvijek nemate račun, možete se registrovati koristeći :socialAccount opciju.',
+    'social_driver_not_found' => 'Driver društvene mreže nije pronađen',
+    'social_driver_not_configured' => 'Vaše :socialAccount postavke nisu konfigurisane ispravno.',
+    'invite_token_expired' => 'Pozivni link je istekao. Možete umjesto toga pokušati da resetujete lozinku.',
+
+    // System
+    'path_not_writable' => 'Na putanju fajla :filePath se ne može učitati. Potvrdite da je omogućeno pisanje na server.',
+    'cannot_get_image_from_url' => 'Nije moguće dobiti sliku sa :url',
+    'cannot_create_thumbs' => 'Server ne može kreirati sličice. Provjerite da imate instaliranu GD PHP ekstenziju.',
+    'server_upload_limit' => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.',
+    'uploaded'  => 'Server ne dopušta učitavanja ove veličine. Pokušajte sa manjom veličinom fajla.',
+    'image_upload_error' => 'Desila se greška prilikom učitavanja slike',
+    'image_upload_type_error' => 'Vrsta slike koja se učitava je neispravna',
+    'file_upload_timeout' => 'Vrijeme učitavanja fajla je isteklo.',
+
+    // Attachments
+    'attachment_not_found' => 'Prilog nije pronađen',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Snimanje skice nije uspjelo. Provjerite da ste povezani na internet prije snimanja ove stranice',
+    'page_custom_home_deletion' => 'Stranicu nije moguće izbrisati dok se koristi kao početna stranica',
+
+    // Entities
+    'entity_not_found' => 'Entitet nije pronađen',
+    'bookshelf_not_found' => 'Polica za knjige 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' => 'Odabrana knjiga ili poglavlje nije pronađeno',
+    'guests_cannot_save_drafts' => 'Gosti ne mogu snimati skice',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Ne možete izbrisati jedinog administratora',
+    'users_cannot_delete_guest' => 'Ne možete izbrisati gost korisnika',
+
+    // Roles
+    'role_cannot_be_edited' => 'Ova uloga ne može biti mijenjana',
+    'role_system_cannot_be_deleted' => 'Ova uloga je sistemska uloga i ne može biti izbrisana',
+    'role_registration_default_cannot_delete' => 'Ova uloga ne može biti izbrisana dok je postavljena kao osnovna registracijska uloga',
+    'role_cannot_remove_only_admin' => 'Ovaj korisnik je jedini korisnik sa ulogom administratora. Postavite ulogu administratora drugom korisniku prije nego je uklonite ovdje.',
+
+    // Comments
+    'comment_list' => 'Desila se greška prilikom dobavljanja komentara.',
+    'cannot_add_comment_to_draft' => 'Ne možete dodati komentare na skicu.',
+    'comment_add' => 'Desila se greška prilikom dodavanja / ažuriranja komentara.',
+    'comment_delete' => 'Desila se greška prilikom brisanja komentara.',
+    'empty_comment' => 'Nemoguće dodati prazan komentar.',
+
+    // Error pages
+    '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',
+    'back_soon' => 'Biti će uskoro u funkciji.',
+
+    // API errors
+    'api_no_authorization_found' => 'Na zahtjevu nije pronađen token za autorizaciju',
+    'api_bad_authorization_format' => 'Token za autorizaciju je pronađen u zahtjevu ali je format neispravan',
+    'api_user_token_not_found' => 'Nije pronađen odgovarajući API token za pruženi token autorizacije',
+    'api_incorrect_token_secret' => 'Tajni ključ naveden za dati korišteni API token nije tačan',
+    'api_user_no_api_permission' => 'Vlasnik korištenog API tokena nema dozvolu za upućivanje API poziva',
+    'api_user_token_expired' => 'Autorizacijski token je istekao',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Došlo je do greške prilikom slanja testnog e-maila:',
+
+];
diff --git a/resources/lang/bs/pagination.php b/resources/lang/bs/pagination.php
new file mode 100644 (file)
index 0000000..a8d1417
--- /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; Prethodna',
+    'next'     => 'Sljedeća &raquo;',
+
+];
diff --git a/resources/lang/bs/passwords.php b/resources/lang/bs/passwords.php
new file mode 100644 (file)
index 0000000..0383e8a
--- /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' => 'Lozinke moraju sadržavati najmanje osam karaktera i podudarati se sa potvrdom lozinke.',
+    'user' => "Ne možemo naći korisnika sa tom e-mail adresom.",
+    'token' => 'Token za poništavanje lozinke nije validan za ovu e-mail adresu.',
+    'sent' => 'Poslali smo link za poništavanje vaše lozinke na e-mail!',
+    'reset' => 'Vaša lozinka je resetovana!',
+
+];
diff --git a/resources/lang/bs/settings.php b/resources/lang/bs/settings.php
new file mode 100644 (file)
index 0000000..0ab168b
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Settings',
+    'settings_save' => 'Save Settings',
+    'settings_save_success' => 'Settings saved',
+
+    // 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.',
+
+    // 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',
+
+    // 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',
+
+    // 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',
+
+    // 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_parent' => 'Parent',
+    '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_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.',
+
+    // 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_ip' => 'IP Address',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range 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_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    '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_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.',
+    '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',
+
+    // 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_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',
+    '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',
+
+    //! 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',
+        '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/bs/validation.php b/resources/lang/bs/validation.php
new file mode 100644 (file)
index 0000000..d6887cc
--- /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 nije ispravan URL.',
+    'after'                => ':attribute mora biti datum 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 datum 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 karaktera.',
+        'array'   => ':attribute mora imati između :min i :max stavki.',
+    ],
+    'boolean'              => ':attribute polje mora biti tačno ili netačno.',
+    'confirmed'            => ':attribute potvrda se ne slaže.',
+    'date'                 => ':attribute nije ispravan datum.',
+    'date_format'          => ':attribute ne odgovara formatu :format.',
+    'different'            => ':attribute i :other moraju biti različiti.',
+    'digits'               => ':attribute mora imati :digits brojeve.',
+    'digits_between'       => ':attribute mora imati između :min i :max brojeva.',
+    'email'                => ':attribute mora biti ispravna e-mail adresa.',
+    'ends_with' => ':attribute mora završavati sa jednom od sljedećih: :values',
+    'filled'               => 'Polje :attribute je obavezno.',
+    'gt'                   => [
+        'numeric' => ':attribute mora biti veći od :value.',
+        'file'    => ':attribute mota biti veći od :value kilobajta.',
+        'string'  => ':attribute mora imati više od :value karaktera.',
+        'array'   => ':attribute mora imati više od :value stavki.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute mora biti veći od ili jednak :value.',
+        'file'    => ':attribute mora imati više od ili jednako :value kilobajta.',
+        'string'  => ':attribute mora imati više od ili jednako :value karaktera.',
+        'array'   => ':attribute mora imati :value stavki ili više.',
+    ],
+    'exists'               => 'Odabrani :attribute je neispravan.',
+    'image'                => ':attribute mora biti slika.',
+    'image_extension'      => ':attribute mora imati ispravnu i podržanu ekstenziju slike.',
+    'in'                   => 'Odabrani :attribute je neispravan.',
+    'integer'              => ':attribute mora biti integer.',
+    'ip'                   => ':attribute mora biti ispravna IP adresa.',
+    'ipv4'                 => ':attribute mora biti ispravna IPv4 adresa.',
+    'ipv6'                 => ':attribute mora biti ispravna IPv6 adresa.',
+    'json'                 => ':attribute mora biti ispravan JSON string.',
+    'lt'                   => [
+        'numeric' => ':attribute mora biti manji od :value.',
+        'file'    => ':attribute mora imati manje od :value kilobajta.',
+        'string'  => ':attribute mora imati manje od :value karaktera.',
+        'array'   => ':attribute mora imati manje od :value stavki.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute mora imati vrijednost manju od ili jednaku :value.',
+        'file'    => ':attribute mora imati manje od ili jednako :value kilobajta.',
+        'string'  => ':attribute mora imati manje od ili jednako :value karaktera.',
+        'array'   => ':attribute ne smije imati više od :value stavki.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute ne može biti veći od :max.',
+        'file'    => ':attribute ne može imati više od :max kilobajta.',
+        'string'  => ':attribute ne može imati više od :max karaktera.',
+        'array'   => ':attribute ne može imati više od :max stavki.',
+    ],
+    'mimes'                => ':attribute mora biti fajl vrste: values.',
+    'min'                  => [
+        'numeric' => ':attribute mora biti najmanje :min.',
+        'file'    => ':attribute mora imati najmanje :min kilobajta.',
+        'string'  => ':attribute mora imati najmanje :min karaktera.',
+        'array'   => ':attribute mora imati najmanje :min stavki.',
+    ],
+    'not_in'               => 'Odabrani :attribute je neispravan.',
+    'not_regex'            => 'Format :attribute je neispravan.',
+    'numeric'              => ':attribute mora biti broj.',
+    'regex'                => 'Format :attribute je neispravan.',
+    'required'             => 'Polje :attribute je obavezno.',
+    'required_if'          => 'Polje :attribute je obavezno kada :other ima vrijednost :value.',
+    'required_with'        => 'Polje :attribute je obavezno kada su prisutne :values.',
+    'required_with_all'    => 'Polje :attribute je obavezno kada su prisutne :values.',
+    'required_without'     => 'Polje :attribute je obavezno kada :values nisu prisutne.',
+    'required_without_all' => 'Polje :attribute je obavezno kada nijedno od :values nije prisutno.',
+    'same'                 => ':attribute i :other se moraju poklapati.',
+    'safe_url'             => 'Navedeni link možda nije siguran.',
+    'size'                 => [
+        'numeric' => ':attribute mora biti :size.',
+        'file'    => ':attribute mora imati :size kilobajta.',
+        'string'  => ':attribute mora imati :size karaktera.',
+        'array'   => ':attribute mora sadržavati :size stavki.',
+    ],
+    '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.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Zahtijeva se potvrda lozinke',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
diff --git a/resources/lang/ca/activities.php b/resources/lang/ca/activities.php
new file mode 100644 (file)
index 0000000..18878c2
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'ha creat la pàgina',
+    'page_create_notification'    => 'Pàgina creada correctament',
+    'page_update'                 => 'ha actualitzat la pàgina',
+    'page_update_notification'    => 'Pàgina actualitzada correctament',
+    'page_delete'                 => 'ha suprimit una pàgina',
+    'page_delete_notification'    => 'Pàgina suprimida correctament',
+    'page_restore'                => 'ha restaurat la pàgina',
+    'page_restore_notification'   => 'Pàgina restaurada correctament',
+    'page_move'                   => 'ha mogut la pàgina',
+
+    // Chapters
+    'chapter_create'              => 'ha creat el capítol',
+    'chapter_create_notification' => 'Capítol creat correctament',
+    'chapter_update'              => 'ha actualitzat el capítol',
+    'chapter_update_notification' => 'Capítol actualitzat correctament',
+    'chapter_delete'              => 'ha suprimit un capítol',
+    'chapter_delete_notification' => 'Capítol suprimit correctament',
+    'chapter_move'                => 'ha mogut el capítol',
+
+    // Books
+    'book_create'                 => 'ha creat el llibre',
+    'book_create_notification'    => 'Llibre creat correctament',
+    'book_update'                 => 'ha actualitzat el llibre',
+    'book_update_notification'    => 'Llibre actualitzat correctament',
+    'book_delete'                 => 'ha suprimit un llibre',
+    'book_delete_notification'    => 'Llibre suprimit correctament',
+    'book_sort'                   => 'ha ordenat el llibre',
+    'book_sort_notification'      => 'Llibre reordenat correctament',
+
+    // Bookshelves
+    'bookshelf_create'            => 'ha creat el prestatge',
+    'bookshelf_create_notification'    => 'Prestatge creat correctament',
+    'bookshelf_update'                 => 'ha actualitzat el prestatge',
+    'bookshelf_update_notification'    => 'Prestatge actualitzat correctament',
+    'bookshelf_delete'                 => 'ha suprimit un prestatge',
+    'bookshelf_delete_notification'    => 'Prestatge suprimit correctament',
+
+    // 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',
+
+    // Other
+    'commented_on'                => 'ha comentat a',
+    'permissions_update'          => 'ha actualitzat els permisos',
+];
diff --git a/resources/lang/ca/auth.php b/resources/lang/ca/auth.php
new file mode 100644 (file)
index 0000000..9febe36
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Les credencials no coincideixen amb les que hi ha emmagatzemades.',
+    'throttle' => 'Massa intents d\'inici de sessió. Torna-ho a provar d\'aquí a :seconds segons.',
+
+    // Login & Register
+    'sign_up' => 'Registra-m\'hi',
+    'log_in' => 'Inicia la sessió',
+    'log_in_with' => 'Inicia la sessió amb :socialDriver',
+    'sign_up_with' => 'Registra-m\'hi amb :socialDriver',
+    'logout' => 'Tanca la sessió',
+
+    'name' => 'Nom',
+    'username' => 'Nom d\'usuari',
+    'email' => 'Adreça electrònica',
+    'password' => 'Contrasenya',
+    'password_confirm' => 'Confirmeu la contrasenya',
+    'password_hint' => 'Cal que tingui més de 7 caràcters',
+    'forgot_password' => 'Heu oblidat la contrasenya?',
+    'remember_me' => 'Recorda\'m',
+    'ldap_email_hint' => 'Introduïu una adreça electrònica per a aquest compte.',
+    'create_account' => 'Crea el compte',
+    'already_have_account' => 'Ja teniu un compte?',
+    'dont_have_account' => 'No teniu cap compte?',
+    'social_login' => 'Inici de sessió amb xarxes social',
+    'social_registration' => 'Registre social',
+    'social_registration_text' => 'Registreu-vos i inicieu la sessió fent servir un altre servei.',
+
+    'register_thanks' => 'Gràcies per registrar-vos!',
+    'register_confirm' => 'Reviseu el vostre correu electrònic i feu clic al botó de confirmació per a accedir a :appName.',
+    'registrations_disabled' => 'Actualment, els registres estan desactivats',
+    '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.',
+    'reset_password_send_button' => 'Envia l\'enllaç de restabliment',
+    'reset_password_sent' => 'S\'enviarà un enllaç per a restablir la contrasenya a :email, si es troba aquesta adreça al sistema.',
+    'reset_password_success' => 'La vostra contrasenya s\'ha restablert correctament.',
+    'email_reset_subject' => 'Restabliu la contrasenya a :appName',
+    '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_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',
+    'email_not_confirmed_text' => 'La vostra adreça electrònica encara no està confirmada.',
+    'email_not_confirmed_click_link' => 'Feu clic a l\'enllaç del correu electrònic que us vam enviar poc després que us registréssiu.',
+    'email_not_confirmed_resend' => 'Si no podeu trobar el correu, podeu tornar a enviar el correu electrònic de confirmació enviant el formulari a continuació.',
+    'email_not_confirmed_resend_button' => 'Torna a enviar el correu de confirmació',
+
+    // User Invite
+    'user_invite_email_subject' => 'Us han convidat a unir-vos a :appName!',
+    'user_invite_email_greeting' => 'Us hem creat un compte en el vostre nom a :appName.',
+    'user_invite_email_text' => 'Feu clic al botó a continuació per a definir una contrasenya per al compte i obtenir-hi accés:',
+    'user_invite_email_action' => 'Defineix una contrasenya per al compte',
+    '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!',
+
+    // 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.',
+];
\ No newline at end of file
diff --git a/resources/lang/ca/common.php b/resources/lang/ca/common.php
new file mode 100644 (file)
index 0000000..4973950
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Cancel·la',
+    'confirm' => 'D\'acord',
+    'back' => 'Enrere',
+    'save' => 'Desa',
+    'continue' => 'Continua',
+    'select' => 'Selecciona',
+    'toggle_all' => 'Commuta-ho tot',
+    'more' => 'Més',
+
+    // Form Labels
+    'name' => 'Nom',
+    'description' => 'Descripció',
+    'role' => 'Rol',
+    'cover_image' => 'Imatge de portada',
+    'cover_image_description' => 'Aquesta imatge hauria de fer aproximadament 440x250 px.',
+    
+    // Actions
+    'actions' => 'Accions',
+    'view' => 'Visualitza',
+    'view_all' => 'Visualitza-ho tot',
+    'create' => 'Crea',
+    'update' => 'Actualitza',
+    'edit' => 'Edita',
+    'sort' => 'Ordena',
+    'move' => 'Mou',
+    'copy' => 'Copia',
+    'reply' => 'Respon',
+    'delete' => 'Suprimeix',
+    'delete_confirm' => 'Confirma la supressió',
+    'search' => 'Cerca',
+    'search_clear' => 'Esborra la cerca',
+    'reset' => 'Reinicialitza',
+    'remove' => 'Elimina',
+    'add' => 'Afegeix',
+    'configure' => 'Configure',
+    'fullscreen' => 'Pantalla completa',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
+
+    // Sort Options
+    'sort_options' => 'Opcions d\'ordenació',
+    'sort_direction_toggle' => 'Commuta la direcció de l\'ordenació',
+    'sort_ascending' => 'Ordre ascendent',
+    'sort_descending' => 'Ordre descendent',
+    'sort_name' => 'Nom',
+    'sort_default' => 'Per defecte',
+    'sort_created_at' => 'Data de creació',
+    'sort_updated_at' => 'Data d\'actualització',
+
+    // Misc
+    'deleted_user' => 'Usuari eliminat',
+    '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',
+    'grid_view' => 'Visualització en graella',
+    'list_view' => 'Visualització en llista',
+    'default' => 'Per defecte',
+    'breadcrumb' => 'Ruta de navegació',
+
+    // Header
+    'header_menu_expand' => 'Expand Header Menu',
+    'profile_menu' => 'Menú del perfil',
+    'view_profile' => 'Mostra el perfil',
+    'edit_profile' => 'Edita el perfil',
+    'dark_mode' => 'Mode fosc',
+    'light_mode' => 'Mode clar',
+
+    // Layout tabs
+    'tab_info' => 'Informació',
+    'tab_info_label' => 'Tab: Show Secondary Information',
+    'tab_content' => 'Contingut',
+    'tab_content_label' => 'Tab: Show Primary Content',
+
+    // Email Content
+    'email_action_help' => 'Si teniu problemes per fer clic al botó ":actionText", copieu i enganxeu l\'URL següent al vostre navegador web:',
+    'email_rights' => 'Tots els drets reservats',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Política de privadesa',
+    'terms_of_service' => 'Condicions del servei',
+];
diff --git a/resources/lang/ca/components.php b/resources/lang/ca/components.php
new file mode 100644 (file)
index 0000000..d96582d
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Selecciona una imatge',
+    'image_all' => 'Totes',
+    'image_all_title' => 'Mostra totes les imatges',
+    'image_book_title' => 'Mostra les imatges pujades a aquest llibre',
+    'image_page_title' => 'Mostra les imatges pujades a aquesta pàgina',
+    'image_search_hint' => 'Cerca per nom d\'imatge',
+    'image_uploaded' => 'Pujada :uploadedDate',
+    'image_load_more' => 'Carrega\'n més',
+    'image_image_name' => 'Nom de la imatge',
+    'image_delete_used' => 'Aquesta imatge s\'utilitza a les pàgines següents.',
+    'image_delete_confirm_text' => 'Segur que voleu suprimir aquesta imatge?',
+    'image_select_image' => 'Selecciona una imatge',
+    'image_dropzone' => 'Arrossegueu imatges o feu clic aquí per a pujar-les',
+    'images_deleted' => 'Imatges suprimides',
+    'image_preview' => 'Previsualització de la imatge',
+    'image_upload_success' => 'Imatge pujada correctament',
+    'image_update_success' => 'Detalls de la imatge actualitzats correctament',
+    'image_delete_success' => 'Imatge suprimida correctament',
+    'image_upload_remove' => 'Suprimeix',
+
+    // Code Editor
+    'code_editor' => 'Edita el codi',
+    'code_language' => 'Llenguatge del codi',
+    'code_content' => 'Contingut del codi',
+    'code_session_history' => 'Historial de la sessió',
+    'code_save' => 'Desa el codi',
+];
diff --git a/resources/lang/ca/entities.php b/resources/lang/ca/entities.php
new file mode 100644 (file)
index 0000000..d3abdea
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Creat fa poc',
+    'recently_created_pages' => 'Pàgines creades fa poc',
+    'recently_updated_pages' => 'Pàgines actualitzades fa poc',
+    'recently_created_chapters' => 'Capítols creats fa poc',
+    'recently_created_books' => 'Llibres creats fa poc',
+    'recently_created_shelves' => 'Prestatges creats fa poc',
+    'recently_update' => 'Actualitzat fa poc',
+    'recently_viewed' => 'Vist fa poc',
+    'recent_activity' => 'Activitat recent',
+    'create_now' => 'Crea\'n ara',
+    'revisions' => 'Revisions',
+    'meta_revision' => 'Revisió núm. :revisionCount',
+    'meta_created' => 'Creat :timeLength',
+    'meta_created_name' => 'Creat :timeLength per :user',
+    'meta_updated' => 'Actualitzat :timeLength',
+    'meta_updated_name' => 'Actualitzat :timeLength per :user',
+    'meta_owned_name' => 'Propietat de :user',
+    'entity_select' => 'Selecciona una entitat',
+    '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',
+    'export' => 'Exporta',
+    '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',
+    'permissions_intro' => 'Si els activeu, aquests permisos tindran més prioritat que qualsevol permís de rol.',
+    'permissions_enable' => 'Activa els permisos personalitzats',
+    'permissions_save' => 'Desa els permisos',
+    'permissions_owner' => 'Propietari',
+
+    // Search
+    'search_results' => 'Resultats de la cerca',
+    'search_total_results_found' => 'S\'ha trobat :count resultat en total|S\'han trobat :count resultats en total',
+    'search_clear' => 'Esborra la cerca',
+    'search_no_pages' => 'La cerca no coincideix amb cap pàgina',
+    'search_for_term' => 'Cerca :term',
+    'search_more' => 'Més resultats',
+    'search_advanced' => 'Cerca avançada',
+    'search_terms' => 'Termes de la cerca',
+    'search_content_type' => 'Tipus de contingut',
+    'search_exact_matches' => 'Coincidències exactes',
+    'search_tags' => 'Cerca d\'etiquetes',
+    'search_options' => 'Opcions',
+    'search_viewed_by_me' => 'Visualitzat per mi',
+    'search_not_viewed_by_me' => 'No visualitzat per mi',
+    'search_permissions_set' => 'Amb permisos definits',
+    'search_created_by_me' => 'Creat per mi',
+    'search_updated_by_me' => 'Actualitzat per mi',
+    'search_owned_by_me' => 'Owned by me',
+    'search_date_options' => 'Opcions de dates',
+    'search_updated_before' => 'Actualitzat abans de',
+    'search_updated_after' => 'Actualitzat després de',
+    'search_created_before' => 'Creat abans de',
+    'search_created_after' => 'Creat després de',
+    'search_set_date' => 'Defineix una data',
+    'search_update' => 'Actualitza la cerca',
+
+    // Shelves
+    'shelf' => 'Prestatge',
+    'shelves' => 'Prestatges',
+    'x_shelves' => ':count prestatge|:count prestatges',
+    'shelves_long' => 'Prestatges',
+    'shelves_empty' => 'No hi ha cap prestatge creat',
+    'shelves_create' => 'Crea un prestatge nou',
+    'shelves_popular' => 'Prestatges populars',
+    'shelves_new' => 'Prestatges nous',
+    'shelves_new_action' => 'Prestatge nou',
+    'shelves_popular_empty' => 'Aquí apareixeran els prestatges més populars.',
+    'shelves_new_empty' => 'Aquí apareixeran els prestatges creats fa poc.',
+    'shelves_save' => 'Desa el prestatge',
+    'shelves_books' => 'Llibres en aquest prestatge',
+    'shelves_add_books' => 'Afegeix llibres a aquest prestatge',
+    'shelves_drag_books' => 'Arrossegueu llibres aquí per a afegir-los a aquest prestatge',
+    'shelves_empty_contents' => 'Aquest prestatge no té cap llibre assignat',
+    'shelves_edit_and_assign' => 'Editeu el prestatge per a assignar-hi llibres',
+    'shelves_edit_named' => 'Edita el prestatge :name',
+    'shelves_edit' => 'Edita el prestatge',
+    'shelves_delete' => 'Suprimeix el prestatge',
+    'shelves_delete_named' => 'Suprimeix el prestatge :name',
+    'shelves_delete_explain' => "Se suprimirà el prestatge amb el nom ':name'. Els llibres que contingui no se suprimiran.",
+    'shelves_delete_confirmation' => 'Segur que voleu suprimir aquest prestatge?',
+    '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.',
+    'shelves_copy_permission_success' => 'S\'han copiat els permisos del prestatge a :count llibres',
+
+    // Books
+    'book' => 'Llibre',
+    'books' => 'Llibres',
+    'x_books' => ':count llibre|:count llibres',
+    'books_empty' => 'No hi ha cap llibre creat',
+    'books_popular' => 'Llibres populars',
+    'books_recent' => 'Llibres recents',
+    'books_new' => 'Llibres nous',
+    'books_new_action' => 'Llibre nou',
+    'books_popular_empty' => 'Aquí apareixeran els llibres més populars.',
+    'books_new_empty' => 'Aquí apareixeran els llibres creats fa poc.',
+    'books_create' => 'Crea un llibre nou',
+    'books_delete' => 'Suprimeix el llibre',
+    'books_delete_named' => 'Suprimeix el llibre :bookName',
+    'books_delete_explain' => 'Se suprimirà el llibre amb el nom \':bookName\'. Se\'n suprimiran les pàgines i els capítols.',
+    'books_delete_confirmation' => 'Segur que voleu suprimir aquest llibre?',
+    'books_edit' => 'Edita el llibre',
+    'books_edit_named' => 'Edita el llibre :bookName',
+    'books_form_book_name' => 'Nom del llibre',
+    'books_save' => 'Desa el llibre',
+    'books_permissions' => 'Permisos del llibre',
+    'books_permissions_updated' => 'S\'han actualitzat els permisos del llibre',
+    'books_empty_contents' => 'No hi ha cap pàgina ni cap capítol creat en aquest llibre.',
+    'books_empty_create_page' => 'Crea una pàgina nova',
+    'books_empty_sort_current_book' => 'Ordena el llibre actual',
+    'books_empty_add_chapter' => 'Afegeix un capítol',
+    'books_permissions_active' => 'S\'han activat els permisos del llibre',
+    'books_search_this' => 'Cerca en aquest llibre',
+    'books_navigation' => 'Navegació pel llibre',
+    'books_sort' => 'Ordena el contingut del llibre',
+    'books_sort_named' => 'Ordena el llibre :bookName',
+    'books_sort_name' => 'Ordena per nom',
+    'books_sort_created' => 'Ordena per data de creació',
+    'books_sort_updated' => 'Ordena per data d\'actualització',
+    'books_sort_chapters_first' => 'Els capítols al principi',
+    'books_sort_chapters_last' => 'Els capítols al final',
+    'books_sort_show_other' => 'Mostra altres llibres',
+    'books_sort_save' => 'Desa l\'ordre nou',
+
+    // Chapters
+    'chapter' => 'Capítol',
+    'chapters' => 'Capítols',
+    'x_chapters' => ':count capítol|:count capítols',
+    'chapters_popular' => 'Capítols populars',
+    'chapters_new' => 'Capítol nou',
+    'chapters_create' => 'Crea un capítol nou',
+    'chapters_delete' => 'Suprimeix el capítol',
+    'chapters_delete_named' => 'Suprimeix el capítol :chapterName',
+    'chapters_delete_explain' => 'Se suprimirà el capítol amb el nom \':chapterName\'. Totes les pàgines que contingui també se suprimiran.',
+    'chapters_delete_confirm' => 'Segur que voleu suprimir aquest capítol?',
+    'chapters_edit' => 'Edita el capítol',
+    'chapters_edit_named' => 'Edita el capítol :chapterName',
+    'chapters_save' => 'Desa el capítol',
+    '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_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',
+    'chapters_permissions_success' => 'S\'han actualitzat els permisos del capítol',
+    'chapters_search_this' => 'Cerca en aquest capítol',
+
+    // Pages
+    'page' => 'Pàgina',
+    'pages' => 'Pàgines',
+    'x_pages' => ':count pàgina|:count pàgines',
+    'pages_popular' => 'Pàgines populars',
+    'pages_new' => 'Pàgina nova',
+    'pages_attachments' => 'Adjuncions',
+    'pages_navigation' => 'Navegació per la pàgina',
+    'pages_delete' => 'Suprimeix la pàgina',
+    'pages_delete_named' => 'Suprimeix la pàgina :pageName',
+    'pages_delete_draft_named' => 'Suprimeix l\'esborrany de pàgina :pageName',
+    'pages_delete_draft' => 'Suprimeix l\'esborrany de pàgina',
+    'pages_delete_success' => 'S\'ha suprimit la pàgina',
+    'pages_delete_draft_success' => 'S\'ha suprimit l\'esborrany de pàgina',
+    'pages_delete_confirm' => 'Segur que voleu suprimir aquesta pàgina?',
+    'pages_delete_draft_confirm' => 'Segur que voleu suprimir aquest esborrany de pàgina?',
+    'pages_editing_named' => 'Esteu editant :pageName',
+    'pages_edit_draft_options' => 'Opcions d\'esborrany',
+    'pages_edit_save_draft' => 'Desa l\'esborrany',
+    'pages_edit_draft' => 'Edita l\'esborrany de pàgina',
+    'pages_editing_draft' => 'Esteu editant l\'esborrany',
+    'pages_editing_page' => 'Esteu editant la pàgina',
+    'pages_edit_draft_save_at' => 'Esborrany desat ',
+    'pages_edit_delete_draft' => 'Suprimeix l\'esborrany',
+    'pages_edit_discard_draft' => 'Descarta l\'esborrany',
+    'pages_edit_set_changelog' => 'Defineix el registre de canvis',
+    'pages_edit_enter_changelog_desc' => 'Introduïu una breu descripció dels canvis que heu fet',
+    'pages_edit_enter_changelog' => 'Introduïu un registre de canvis',
+    'pages_save' => 'Desa la pàgina',
+    'pages_title' => 'Títol de la pàgina',
+    'pages_name' => 'Nom de la pàgina',
+    'pages_md_editor' => 'Editor',
+    'pages_md_preview' => 'Previsualització',
+    'pages_md_insert_image' => 'Insereix una imatge',
+    'pages_md_insert_link' => 'Insereix un enllaç a una entitat',
+    'pages_md_insert_drawing' => 'Insereix un diagrama',
+    'pages_not_in_chapter' => 'La pàgina no pertany a cap capítol',
+    'pages_move' => 'Mou la pàgina',
+    'pages_move_success' => 'S\'ha mogut la pàgina a ":parentName"',
+    'pages_copy' => 'Copia la pàgina',
+    'pages_copy_desination' => 'Destinació de la còpia',
+    'pages_copy_success' => 'Pàgina copiada correctament',
+    'pages_permissions' => 'Permisos de la pàgina',
+    'pages_permissions_success' => 'S\'han actualitzat els permisos de la pàgina',
+    'pages_revision' => 'Revisió',
+    'pages_revisions' => 'Revisions de la pàgina',
+    'pages_revisions_named' => 'Revisions de la pàgina :pageName',
+    'pages_revision_named' => 'Revisió de la pàgina :pageName',
+    'pages_revision_restored_from' => 'Restaurada de núm. :id; :summary',
+    'pages_revisions_created_by' => 'Creada per',
+    'pages_revisions_date' => 'Data de la revisió',
+    'pages_revisions_number' => 'Núm. ',
+    'pages_revisions_numbered' => 'Revisió núm. :id',
+    'pages_revisions_numbered_changes' => 'Canvis de la revisió núm. :id',
+    'pages_revisions_changelog' => 'Registre de canvis',
+    'pages_revisions_changes' => 'Canvis',
+    'pages_revisions_current' => 'Versió actual',
+    'pages_revisions_preview' => 'Previsualitza',
+    'pages_revisions_restore' => 'Restaura',
+    'pages_revisions_none' => 'Aquesta pàgina no té cap revisió',
+    'pages_copy_link' => 'Copia l\'enllaç',
+    'pages_edit_content_link' => 'Edita el contingut',
+    'pages_permissions_active' => 'S\'han activat els permisos de la pàgina',
+    'pages_initial_revision' => 'Publicació inicial',
+    '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_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',
+        'time_a' => 'd\'ençà que la pàgina es va actualitzar per darrer cop',
+        'time_b' => 'en els darrers :minCount minuts',
+        'message' => ':start :time. Aneu amb compte de no trepitjar-vos les actualitzacions entre vosaltres!',
+    ],
+    'pages_draft_discarded' => 'S\'ha descartat l\'esborrany, l\'editor s\'ha actualitzat amb el contingut actual de la pàgina',
+    'pages_specific' => 'Una pàgina específica',
+    'pages_is_template' => 'Plantilla de pàgina',
+
+    // Editor Sidebar
+    'page_tags' => 'Etiquetes de la pàgina',
+    'chapter_tags' => 'Etiquetes del capítol',
+    'book_tags' => 'Etiquetes del llibre',
+    'shelf_tags' => 'Etiquetes del prestatge',
+    'tag' => 'Etiqueta',
+    'tags' =>  'Etiquetes',
+    'tag_name' =>  'Nom de l\'etiqueta',
+    'tag_value' => 'Valor de l\'etiqueta (opcional)',
+    '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',
+    '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.',
+    'attachments_items' => 'Elements adjunts',
+    'attachments_upload' => 'Puja un fitxer',
+    'attachments_link' => 'Adjunta un enllaç',
+    'attachments_set_link' => 'Defineix l\'enllaç',
+    'attachments_delete' => 'Seguir que voleu suprimir aquesta adjunció?',
+    'attachments_dropzone' => 'Arrossegueu fitxers o feu clic aquí per a adjuntar un fitxer',
+    'attachments_no_files' => 'No s\'ha pujat cap fitxer',
+    'attachments_explain_link' => 'Podeu adjuntar un enllaç si preferiu no pujar un fitxer. Pot ser un enllaç a una altra pàgina o un enllaç a un fitxer al núvol.',
+    'attachments_link_name' => 'Nom de l\'enllaç',
+    'attachment_link' => 'Enllaç de l\'adjunció',
+    'attachments_link_url' => 'Enllaç al fitxer',
+    'attachments_link_url_hint' => 'URL del lloc o fitxer',
+    'attach' => 'Adjunta',
+    'attachments_insert_link' => 'Afegeix un enllaç de l\'adjunció a la pàgina',
+    'attachments_edit_file' => 'Edita el fitxer',
+    'attachments_edit_file_name' => 'Nom del fitxer',
+    'attachments_edit_drop_upload' => 'Arrossegueu fitxers o feu clic aquí per a pujar-los i sobreescriure\'ls',
+    'attachments_order_updated' => 'S\'ha actualitzat l\'ordre de les adjuncions',
+    'attachments_updated_success' => 'S\'han actualitzat els detalls de les adjuncions',
+    'attachments_deleted' => 'S\'ha suprimit l\'adjunció',
+    'attachments_file_uploaded' => 'Fitxer pujat correctament',
+    'attachments_file_updated' => 'Fitxer actualitzat correctament',
+    'attachments_link_attached' => 'Enllaç adjuntat a la pàgina correctament',
+    'templates' => 'Plantilles',
+    'templates_set_as_template' => 'La pàgina és una plantilla',
+    'templates_explain_set_as_template' => 'Podeu definir aquesta pàgina com a plantilla perquè el seu contingut es pugui fer servir en crear altres pàgines. Els altres usuaris podran fer servir la plantilla si tenen permís per a veure aquesta pàgina.',
+    'templates_replace_content' => 'Substitueix el contingut de la pàgina',
+    'templates_append_content' => 'Afegeix al final del contingut de la pàgina',
+    'templates_prepend_content' => 'Afegeix al principi del contingut de la pàgina',
+
+    // Profile View
+    'profile_user_for_x' => 'Usuari fa :time',
+    'profile_created_content' => 'Contingut creat',
+    'profile_not_created_pages' => ':userName no ha creat cap pàgina',
+    'profile_not_created_chapters' => ':userName no ha creat cap capítol',
+    'profile_not_created_books' => ':userName no ha creat cap llibre',
+    'profile_not_created_shelves' => ':userName no ha creat cap prestatge',
+
+    // Comments
+    'comment' => 'Comentari',
+    'comments' => 'Comentaris',
+    'comment_add' => 'Afegeix un comentari',
+    'comment_placeholder' => 'Deixeu un comentari aquí',
+    'comment_count' => '{0} Sense comentaris|{1} 1 comentari|[2,*] :count comentaris',
+    'comment_save' => 'Desa el comentari',
+    'comment_saving' => 'S\'està desant el comentari...',
+    'comment_deleting' => 'S\'està suprimint el comentari...',
+    'comment_new' => 'Comentari nou',
+    'comment_created' => 'ha comentat :createDiff',
+    'comment_updated' => 'Actualitzat :updateDiff per :username',
+    'comment_deleted_success' => 'Comentari suprimit',
+    'comment_created_success' => 'Comentari afegit',
+    'comment_updated_success' => 'Comentari actualitzat',
+    'comment_delete_confirm' => 'Segur que voleu suprimir aquest comentari?',
+    'comment_in_reply_to' => 'En resposta a :commentId',
+
+    // Revision
+    '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ó.'
+];
diff --git a/resources/lang/ca/errors.php b/resources/lang/ca/errors.php
new file mode 100644 (file)
index 0000000..1a413ba
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'No teniu permís per a accedir a la pàgina sol·licitada.',
+    'permissionJson' => 'No teniu permís per a executar l\'acció sol·licitada.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Ja hi ha un usuari amb l\'adreça electrònica :email però amb credencials diferents.',
+    'email_already_confirmed' => 'L\'adreça electrònica ja està confirmada. Proveu d\'iniciar la sessió.',
+    'email_confirmation_invalid' => 'Aquest testimoni de confirmació no és vàlid o ja ha estat utilitzat. Proveu de tornar-vos a registrar.',
+    'email_confirmation_expired' => 'El testimoni de confirmació ha caducat. S\'ha enviat un nou correu electrònic de confirmació.',
+    'email_confirmation_awaiting' => 'Cal confirmar l\'adreça electrònica del compte que utilitzeu',
+    'ldap_fail_anonymous' => 'L\'accés a l\'LDAP ha fallat fent servir un lligam anònim',
+    'ldap_fail_authed' => 'L\'accés a l\'LDAP ha fallat fent servir els detalls de DN i contrasenya proporcionats',
+    'ldap_extension_not_installed' => 'L\'extensió de l\'LDAP de PHP no està instal·lada',
+    'ldap_cannot_connect' => 'No s\'ha pogut connectar amb el servidor de l\'LDAP, la connexió inicial ha fallat',
+    'saml_already_logged_in' => 'Ja heu iniciat la sessió',
+    'saml_user_not_registered' => 'L\'usuari :name no està registrat i els registres automàtics estan desactivats',
+    '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',
+    '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.',
+    'social_account_email_in_use' => 'L\'adreça electrònica :email ja està en ús. Si ja teniu un compte, podeu connectar-hi el vostre compte de :socialAccount a la configuració del vostre perfil.',
+    'social_account_existing' => 'Aquest compte de :socialAccount ja està associat al vostre perfil.',
+    'social_account_already_used_existing' => 'Aquest compte de :socialAccount ja el fa servir un altre usuari.',
+    'social_account_not_used' => 'Aquest compte de :socialAccount no està associat a cap usuari. Associeu-lo a la configuració del vostre perfil. ',
+    'social_account_register_instructions' => 'Si encara no teniu cap compte, podeu registrar-vos fent servir l\'opció de :socialAccount.',
+    'social_driver_not_found' => 'No s\'ha trobat el controlador social',
+    'social_driver_not_configured' => 'La configuració social de :socialAccount no és correcta.',
+    'invite_token_expired' => 'Aquest enllaç d\'invitació ha caducat. Podeu provar de restablir la contrasenya del vostre compte.',
+
+    // System
+    'path_not_writable' => 'No s\'ha pogut pujar al camí del fitxer :filePath. Assegureu-vos que el servidor hi té permisos d\'escriptura.',
+    'cannot_get_image_from_url' => 'No s\'ha pogut obtenir la imatge de :url',
+    'cannot_create_thumbs' => 'El servidor no pot crear miniatures. Reviseu que tingueu instal·lada l\'extensió GD del PHP.',
+    'server_upload_limit' => 'El servidor no permet pujades d\'aquesta mida. Proveu-ho amb una mida de fitxer més petita.',
+    'uploaded'  => 'El servidor no permet pujades d\'aquesta mida. Proveu-ho amb una mida de fitxer més petita.',
+    'image_upload_error' => 'S\'ha produït un error en pujar la imatge',
+    'image_upload_type_error' => 'El tipus d\'imatge que heu pujat no és vàlid',
+    'file_upload_timeout' => 'La pujada del fitxer ha superat el temps màxim d\'espera.',
+
+    // Attachments
+    'attachment_not_found' => 'No s\'ha trobat l\'adjunció',
+
+    // Pages
+    'page_draft_autosave_fail' => 'No s\'ha pogut desar l\'esborrany. Assegureu-vos que tingueu connexió a Internet abans de desar la pàgina',
+    'page_custom_home_deletion' => 'No es pot suprimir una pàgina mentre estigui definida com a pàgina d\'inici',
+
+    // Entities
+    'entity_not_found' => 'No s\'ha trobat l\'entitat',
+    'bookshelf_not_found' => 'No s\'ha trobat el prestatge',
+    'book_not_found' => 'No s\'ha trobat el llibre',
+    'page_not_found' => 'No s\'ha trobat la pàgina',
+    'chapter_not_found' => 'No s\'ha trobat el capítol',
+    'selected_book_not_found' => 'No s\'ha trobat el llibre seleccionat',
+    'selected_book_chapter_not_found' => 'No s\'ha trobat el llibre o el capítol seleccionat',
+    'guests_cannot_save_drafts' => 'Els convidats no poden desar esborranys',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'No podeu suprimir l\'únic administrador',
+    'users_cannot_delete_guest' => 'No podeu suprimir l\'usuari convidat',
+
+    // Roles
+    'role_cannot_be_edited' => 'Aquest rol no es pot editar',
+    'role_system_cannot_be_deleted' => 'Aquest rol és un rol del sistema i no es pot suprimir',
+    'role_registration_default_cannot_delete' => 'No es pot suprimir aquest rol mentre estigui definit com a rol per defecte dels registres',
+    'role_cannot_remove_only_admin' => 'Aquest usuari és l\'únic usuari assignat al rol d\'administrador. Assigneu el rol d\'administrador a un altre usuari abans de provar de suprimir aquest.',
+
+    // Comments
+    'comment_list' => 'S\'ha produït un error en obtenir els comentaris.',
+    'cannot_add_comment_to_draft' => 'No podeu afegir comentaris a un esborrany.',
+    'comment_add' => 'S\'ha produït un error en afegir o actualitzar el comentari.',
+    'comment_delete' => 'S\'ha produït un error en suprimir el comentari.',
+    'empty_comment' => 'No podeu afegir un comentari buit.',
+
+    // Error pages
+    '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',
+    'back_soon' => 'Tornarà a estar disponible aviat.',
+
+    // API errors
+    'api_no_authorization_found' => 'No s\'ha trobat cap testimoni d\'autorització a la petició',
+    'api_bad_authorization_format' => 'S\'ha trobat un testimoni d\'autorització a la petició però el format sembla erroni',
+    'api_user_token_not_found' => 'No s\'ha trobat cap testimoni d\'API per al testimoni d\'autorització proporcionat',
+    'api_incorrect_token_secret' => 'El secret proporcionat per al testimoni d\'API proporcionat és incorrecte',
+    'api_user_no_api_permission' => 'El propietari del testimoni d\'API utilitzat no té permís per a fer crides a l\'API',
+    'api_user_token_expired' => 'El testimoni d\'autorització utilitzat ha caducat',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'S\'ha produït un error en enviar un correu electrònic de prova:',
+
+];
diff --git a/resources/lang/ca/pagination.php b/resources/lang/ca/pagination.php
new file mode 100644 (file)
index 0000000..f05a8b3
--- /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; Anterior',
+    'next'     => 'Següent &raquo;',
+
+];
diff --git a/resources/lang/ca/passwords.php b/resources/lang/ca/passwords.php
new file mode 100644 (file)
index 0000000..1f0f759
--- /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' => 'Les contrasenyes han de tenir com a mínim vuit caràcters i la confirmació ha de coincidir.',
+    'user' => "No s'ha trobat cap usuari amb aquest correu electrònic.",
+    'token' => 'El token de restabliment de contrasenya no és vàlid per aquest correu electrònic.',
+    'sent' => 'T\'hem enviat un enllaç per a restablir la contrasenya!',
+    'reset' => 'S\'ha restablert la teva contrasenya!',
+
+];
diff --git a/resources/lang/ca/settings.php b/resources/lang/ca/settings.php
new file mode 100755 (executable)
index 0000000..3a3fddd
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Configuració',
+    'settings_save' => 'Desa la configuració',
+    'settings_save_success' => 'S\'ha desat la configuració',
+
+    // App Settings
+    'app_customization' => 'Personalització',
+    'app_features_security' => 'Funcionalitats i seguretat',
+    'app_name' => 'Nom de l\'aplicació',
+    'app_name_desc' => 'Aquest nom es mostra a la capçalera i en tots els correus electrònics enviats pel sistema.',
+    'app_name_header' => 'Mostra el nom a la capçalera',
+    'app_public_access' => 'Accés públic',
+    'app_public_access_desc' => 'Si activeu aquesta opció, es permetrà que els visitants que no hagin iniciat la sessió accedeixin al contingut de la vostra instància del BookStack.',
+    'app_public_access_desc_guest' => 'Podeu controlar l\'accés dels visitants públics amb l\'usuari "Convidat".',
+    'app_public_access_toggle' => 'Permet l\'accés públic',
+    'app_public_viewing' => 'Voleu permetre la visualització pública?',
+    'app_secure_images' => 'Pujades d\'imatges amb més seguretat',
+    'app_secure_images_toggle' => 'Activa les pujades d\'imatges amb més seguretat',
+    'app_secure_images_desc' => 'Per motius de rendiment, totes les imatges són públiques. Aquesta opció afegeix una cadena aleatòria i difícil d\'endevinar al davant dels URL d\'imatges. Assegureu-vos que els índexs de directoris no estiguin activats per a evitar-hi l\'accés de manera fàcil.',
+    'app_editor' => 'Editor de pàgines',
+    'app_editor_desc' => 'Seleccioneu quin editor faran servir tots els usuaris per a editar les pàgines.',
+    'app_custom_html' => 'Contingut personalitzat a la capçalera HTML',
+    'app_custom_html_desc' => 'Aquí podeu afegir contingut que s\'inserirà a la part final de la secció <head> de cada pàgina. És útil per a sobreescriure estils o afegir-hi codi d\'analítiques.',
+    'app_custom_html_disabled_notice' => 'El contingut personalitzat a la capçalera HTML es desactiva en aquesta pàgina de la configuració per a assegurar que qualsevol canvi que trenqui el web es pugui desfer.',
+    'app_logo' => 'Logo de l\'aplicació',
+    'app_logo_desc' => 'Aquesta imatge hauria de tenir 43 px d\'alçada. <br>Les imatges grosses es reduiran.',
+    'app_primary_color' => 'Color primari de l\'aplicació',
+    'app_primary_color_desc' => 'Defineix el color primari de l\'aplicació, incloent-hi la part superior, els botons i els enllaços.',
+    'app_homepage' => 'Pàgina d\'inici de l\'aplicació',
+    'app_homepage_desc' => 'Seleccioneu la visualització que es mostrarà a la pàgina d\'inici en lloc de la visualització per defecte. Els permisos de pàgines s\'ignoraran per a les pàgines seleccionades.',
+    'app_homepage_select' => 'Selecciona una pàgina',
+    'app_footer_links' => 'Enllaços al peu de pàgina',
+    'app_footer_links_desc' => 'Afegiu enllaços que es mostraran al peu de pàgina del lloc. Es mostraran a la part inferior de la majoria de pàgines, incloent-hi les que no requereixen iniciar la sessió. Podeu utilitzar l\'etiqueta "trans::<clau>" per a fer servir traduccions definides pel sistema. Per exemple, si feu servir "trans::common.privacy_policy", es mostrarà el text traduït "Política de privadesa", i amb "trans::common.terms_of_service" es mostrarà el text traduït "Condicions del servei".',
+    'app_footer_links_label' => 'Etiqueta de l\'enllaç',
+    'app_footer_links_url' => 'URL de l\'enllaç',
+    'app_footer_links_add' => 'Afegeix un enllaç al peu de pàgina',
+    'app_disable_comments' => 'Desactiva els comentaris',
+    'app_disable_comments_toggle' => 'Desactiva els comentaris',
+    'app_disable_comments_desc' => 'Desactiva els comentaris a totes les pàgines de l\'aplicació. <br> Els comentaris existents no es mostraran.',
+
+    // Color settings
+    'content_colors' => 'Colors del contingut',
+    'content_colors_desc' => 'Defineix els colors de tots els elements de la jerarquia d\'organització de pàgines. És recomanable triar colors amb una brillantor semblant als colors per defecte per a mantenir-ne la llegibilitat.',
+    'bookshelf_color' => 'Color dels prestatges',
+    'book_color' => 'Color dels llibres',
+    'chapter_color' => 'Color dels capítols',
+    'page_color' => 'Color de les pàgines',
+    'page_draft_color' => 'Color dels esborranys de pàgines',
+
+    // Registration Settings
+    'reg_settings' => 'Registre',
+    'reg_enable' => 'Activa el registre d\'usuaris',
+    'reg_enable_toggle' => 'Activa el registre d\'usuaris',
+    'reg_enable_desc' => 'Si els registres estan activats, els usuaris podran registrar-se ells mateixos com a usuaris de l\'aplicació. Un cop registrats, se\'ls assigna un únic rol d\'usuari per defecte.',
+    'reg_default_role' => 'Rol d\'usuari per defecte en registrar-se',
+    'reg_enable_external_warning' => 'L\'opció anterior s\'ignora quan hi ha activada l\'autenticació SAML o LDAP externa. Els comptes d\'usuari de membres inexistents es creada automàticament si l\'autenticació contra el sistema extern és satisfactòria.',
+    'reg_email_confirmation' => 'Confirmació de correu electrònic',
+    'reg_email_confirmation_toggle' => 'Requereix la confirmació per correu electrònic',
+    'reg_confirm_email_desc' => 'Si s\'utilitza la restricció de dominis, serà obligatòria la confirmació per correu electrònic, i s\'ignorarà aquesta opció.',
+    'reg_confirm_restrict_domain' => 'Restricció de dominis',
+    'reg_confirm_restrict_domain_desc' => 'Introduïu una llista separada per comes de dominis de correu electrònic als quals voleu restringir els registres. S\'enviarà un correu electrònic als usuaris perquè confirmin la seva adreça abans de permetre\'ls interactuar amb l\'aplicació. <br> Tingueu en compte que els usuaris podran canviar les seves adreces electròniques després de registrar-se correctament.',
+    'reg_confirm_restrict_domain_placeholder' => 'No hi ha cap restricció',
+
+    // 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_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?',
+    'maint_image_cleanup_success' => 'S\'han trobat i suprimit :count imatges potencialment no utilitzades!',
+    'maint_image_cleanup_nothing_found' => 'No s\'ha trobat cap imatge no utilitzada, i no s\'ha suprimit res!',
+    'maint_send_test_email' => 'Envia un correu electrònic de prova',
+    'maint_send_test_email_desc' => 'Envia un correu electrònic de prova a l\'adreça electrònica que hàgiu especificat al perfil.',
+    'maint_send_test_email_run' => 'Envia el correu electrònic de prova',
+    'maint_send_test_email_success' => 'S\'ha enviat el correu electrònic a :address',
+    'maint_send_test_email_mail_subject' => 'Correu electrònic de prova',
+    'maint_send_test_email_mail_greeting' => 'El lliurament de correus electrònics sembla que funciona!',
+    'maint_send_test_email_mail_text' => 'Enhorabona! Com que heu rebut aquesta notificació per correu electrònic, la vostra configuració del correu electrònic sembla que està ben configurada.',
+    'maint_recycle_bin_desc' => 'Els prestatges, llibres, capítols i pàgines eliminats s\'envien a la paperera de reciclatge perquè es puguin restaurar o suprimir de manera permanent. Pot ser que els elements més antics de la paperera de reciclatge se suprimeixin automàticament després d\'un temps, depenent de la configuració del sistema.',
+    'maint_recycle_bin_open' => 'Obre la paperera de reciclatge',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Restaura',
+    'recycle_bin_contents_empty' => 'La paperera de reciclatge és buida',
+    'recycle_bin_empty' => 'Buida la paperera de reciclatge',
+    'recycle_bin_empty_confirm' => 'Se suprimiran de manera permanent tots els elements de la paperera de reciclatge, incloent-hi el contingut dins de cada element. Segur que voleu buidar la paperera de reciclatge?',
+    'recycle_bin_destroy_confirm' => 'Aquesta acció suprimirà del sistema de manera permanent aquest element, juntament amb tots els elements fills que es llisten a sota, i no podreu restaurar aquest contingut. Segur que voleu suprimir de manera permanent aquest element?',
+    'recycle_bin_destroy_list' => 'Elements que es destruiran',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Registre d\'auditoria',
+    'audit_desc' => 'Aquest registre d\'auditoria mostra una llista d\'activitats registrades al sistema. Aquesta llista no té cap filtre, al contrari que altres llistes d\'activitat similars en què es tenen en compte els filtres de permisos.',
+    'audit_event_filter' => 'Filtre d\'esdeveniments',
+    'audit_event_filter_no_filter' => 'Sense filtre',
+    'audit_deleted_item' => 'Element suprimit',
+    'audit_deleted_item_name' => 'Nom: :name',
+    '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',
+
+    // Role Settings
+    'roles' => 'Rols',
+    'role_user_roles' => 'Rols d\'usuari',
+    'role_create' => 'Crea un rol nou',
+    'role_create_success' => 'Rol creat correctament',
+    'role_delete' => 'Suprimeix el rol',
+    'role_delete_confirm' => 'Se suprimirà el rol amb el nom \':roleName\'.',
+    'role_delete_users_assigned' => 'Aquest rol té :userCount usuaris assignats. Si voleu migrar els usuaris d\'aquest rol, seleccioneu un rol nou a continuació.',
+    'role_delete_no_migration' => "No migris els usuaris",
+    'role_delete_sure' => 'Segur que voleu suprimir aquest rol?',
+    'role_delete_success' => 'Rol suprimit correctament',
+    'role_edit' => 'Edita el rol',
+    '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',
+    'role_manage_roles' => 'Gestiona rols i permisos de rols',
+    'role_manage_entity_permissions' => 'Gestiona els permisos de tots els llibres, capítols i pàgines',
+    'role_manage_own_entity_permissions' => 'Gestiona els permisos dels llibres, capítols i pàgines propis',
+    '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.',
+    'role_asset_admins' => 'Els administradors tenen accés automàticament a tot el contingut, però aquestes opcions poden mostrar o amagar opcions de la interfície d\'usuari.',
+    'role_all' => 'Tot',
+    'role_own' => 'Propi',
+    'role_controlled_by_asset' => 'Controlat pel recurs en què es pugen',
+    'role_save' => 'Desa el rol',
+    'role_update_success' => 'Rol actualitzat correctament',
+    'role_users' => 'Usuaris amb aquest rol',
+    'role_users_none' => 'Ara mateix no hi ha cap usuari assignat a aquest rol',
+
+    // Users
+    'users' => 'Usuaris',
+    'user_profile' => 'Perfil de l\'usuari',
+    'users_add_new' => 'Afegeix un usuari nou',
+    'users_search' => 'Cerca usuaris',
+    'users_latest_activity' => 'Darrera activitat',
+    'users_details' => 'Detalls de l\'usuari',
+    'users_details_desc' => 'Definiu un nom públic i una adreça electrònica per a aquest usuari. L\'adreça electrònica es farà servir per a iniciar la sessió a l\'aplicació.',
+    'users_details_desc_no_email' => 'Definiu un nom públic per a aquest usuari perquè els altres el puguin reconèixer.',
+    '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_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',
+    'users_external_auth_id_desc' => 'Aquest és l\'identificador que s\'utilitza per a enllaçar aquest usuari en comunicar amb el sistema d\'autenticació extern.',
+    'users_password_warning' => 'Ompliu-ho només si voleu canviar la vostra contrasenya.',
+    'users_system_public' => 'Aquest usuari representa qualsevol usuari convidat que visita la vostra instància. No es pot fer servir per a iniciar la sessió però s\'assigna automàticament.',
+    'users_delete' => 'Suprimeix l\'usuari',
+    'users_delete_named' => 'Suprimeix l\'usuari :userName',
+    'users_delete_warning' => 'Se suprimirà completament del sistema l\'usuari amb el nom \':userName\'.',
+    'users_delete_confirm' => 'Segur que voleu suprimir aquest usuari?',
+    'users_migrate_ownership' => 'Migra l\'autoria',
+    'users_migrate_ownership_desc' => 'Seleccioneu un usuari si voleu que un altre usuari esdevingui el propietari de tots els elements que ara són propietat d\'aquest usuari.',
+    'users_none_selected' => 'No hi ha cap usuari seleccionat',
+    'users_delete_success' => 'Usuari suprimit correctament',
+    'users_edit' => 'Edita l\'usuari',
+    'users_edit_profile' => 'Edita el perfil',
+    'users_edit_success' => 'Usuari actualitzat correctament',
+    'users_avatar' => 'Avatar de l\'usuari',
+    'users_avatar_desc' => 'Seleccioneu una imatge que representi aquest usuari. Hauria de ser un quadrat d\'aproximadament 256 px.',
+    'users_preferred_language' => 'Llengua preferida',
+    'users_preferred_language_desc' => 'Aquesta opció canviarà la llengua utilitzada a la interfície d\'usuari de l\'aplicació. No afectarà el contingut creat pels usuaris.',
+    'users_social_accounts' => 'Comptes socials',
+    'users_social_accounts_info' => 'Aquí podeu connectar altres comptes per a un inici de sessió més ràpid i còmode. Si desconnecteu un compte aquí, no en revoqueu l\'accés d\'autorització donat amb anterioritat. Revoqueu-hi l\'accés a la configuració del perfil del compte social que hàgiu connectat.',
+    'users_social_connect' => 'Connecta un compte',
+    'users_social_disconnect' => 'Desconnecta el compte',
+    'users_social_connected' => 'El compte de :socialAccount s\'ha associat correctament al vostre perfil.',
+    'users_social_disconnected' => 'El compte de :socialAccount s\'ha desassociat correctament del vostre perfil.',
+    'users_api_tokens' => 'Testimonis d\'API',
+    'users_api_tokens_none' => 'No s\'ha creat cap testimoni d\'API per a aquest usuari',
+    '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',
+    'user_api_token_name' => 'Nom',
+    'user_api_token_name_desc' => 'Poseu un nom llegible al vostre testimoni com a recordatori futur del propòsit al qual el voleu destinar.',
+    'user_api_token_expiry' => 'Data de caducitat',
+    'user_api_token_expiry_desc' => 'Definiu una data en què aquest testimoni caducarà. Després d\'aquesta data, les peticions fetes amb aquest testimoni deixaran de funcionar. Si deixeu aquest camp en blanc, es definirà una caducitat d\'aquí a 100 anys..',
+    'user_api_token_create_secret_message' => 'Just després de crear aquest testimoni, es generaran i es mostraran un "Identificador del testimoni" i un "Secret del testimoni". El secret només es mostrarà una única vegada, assegureu-vos de copiar-lo a un lloc segur abans de continuar.',
+    'user_api_token_create_success' => 'Testimoni d\'API creat correctament',
+    'user_api_token_update_success' => 'Testimoni d\'API actualitzat correctament',
+    'user_api_token' => 'Testimoni d\'API',
+    'user_api_token_id' => 'Identificador del testimoni',
+    'user_api_token_id_desc' => 'Aquest identificador és generat pel sistema per a aquest testimoni i no és editable, caldrà que el proporcioneu a les peticions a l\'API.',
+    'user_api_token_secret' => 'Secret del testimoni',
+    'user_api_token_secret_desc' => 'Aquest secret és generat pel sistema per a aquest testimoni, caldrà que el proporcioneu a les peticions a l\'API. Només es mostrarà aquesta única vegada, assegureu-vos de copiar-lo a un lloc segur.',
+    'user_api_token_created' => 'Testimoni creat :timeAgo',
+    'user_api_token_updated' => 'Testimoni actualitzat :timeAgo',
+    'user_api_token_delete' => 'Suprimeix el testimoni',
+    'user_api_token_delete_warning' => 'Se suprimirà completament del sistema aquest testimoni d\'API amb el nom \':tokenName\'.',
+    'user_api_token_delete_confirm' => 'Segur que voleu suprimir aquest testimoni d\'API?',
+    'user_api_token_delete_success' => 'Testimoni d\'API suprimit correctament',
+
+    //! 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',
+        '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/ca/validation.php b/resources/lang/ca/validation.php
new file mode 100644 (file)
index 0000000..603182c
--- /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'             => 'Cal que acceptis :attribute.',
+    'active_url'           => 'L\':attribute no és un URL vàlid.',
+    'after'                => 'El camp :attribute ha de ser una data posterior a :date.',
+    'alpha'                => 'El camp :attribute només pot contenir lletres.',
+    '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.',
+        'file'    => 'El camp :attribute ha de tenir entre :min i :max kilobytes.',
+        'string'  => 'El camp :attribute ha de tenir entre :min i :max caràcters.',
+        'array'   => 'El camp :attribute ha de tenir entre :min i :max elements.',
+    ],
+    'boolean'              => 'El camp :attribute ha de ser cert o fals.',
+    'confirmed'            => 'La confirmació del camp :attribute no coincideix.',
+    'date'                 => 'El camp :attribute no és una data vàlida.',
+    'date_format'          => 'El camp :attribute no coincideix amb el format :format.',
+    'different'            => 'Els camps :attribute i :other han de ser diferents.',
+    'digits'               => 'El camp :attribute ha de tenir :digits dígits.',
+    'digits_between'       => 'El camp :attribute ha de tenir entre :min i :max dígits.',
+    'email'                => 'El camp :attribute ha de ser una adreça electrònica vàlida.',
+    'ends_with' => 'El camp :attribute ha d\'acabar amb un dels següents valors: :values',
+    'filled'               => 'El camp :attribute és obligatori.',
+    'gt'                   => [
+        'numeric' => 'El camp :attribute ha de ser més gran que :value.',
+        'file'    => 'El camp :attribute ha de tenir més de :value kilobytes.',
+        'string'  => 'El camp :attribute ha de tenir més de :value caràcters.',
+        'array'   => 'El camp :attribute ha de tenir més de :value elements.',
+    ],
+    'gte'                  => [
+        'numeric' => 'El camp :attribute ha de ser més gran o igual que :value.',
+        'file'    => 'El camp :attribute ha de tenir :value kilobytes o més.',
+        'string'  => 'El camp :attribute ha de tenir :value caràcters o més.',
+        'array'   => 'El camp :attribute ha de tenir :value elements o més.',
+    ],
+    'exists'               => 'El camp :attribute no és vàlid.',
+    'image'                => 'El camp :attribute ha de ser una imatge.',
+    'image_extension'      => 'El camp :attribute ha de tenir una extensió d\'imatge vàlida i suportada.',
+    'in'                   => 'El camp :attribute seleccionat no és vàlid.',
+    'integer'              => 'El camp :attribute ha de ser un enter.',
+    'ip'                   => 'El camp :attribute ha de ser una adreça IP vàlida.',
+    'ipv4'                 => 'El camp :attribute ha de ser una adreça IPv4 vàlida.',
+    'ipv6'                 => 'El camp :attribute ha de ser una adreça IPv6 vàlida.',
+    'json'                 => 'El camp :attribute ha de ser una cadena JSON vàlida.',
+    'lt'                   => [
+        'numeric' => 'El camp :attribute ha de ser menor que :value.',
+        'file'    => 'El camp :attribute ha de tenir menys de :value kilobytes.',
+        'string'  => 'El camp :attribute ha de tenir menys de :value caràcters.',
+        'array'   => 'El camp :attribute ha de tenir menys de :value elements.',
+    ],
+    'lte'                  => [
+        'numeric' => 'El camp :attribute ha de ser més petit o igual que :value.',
+        'file'    => 'El camp :attribute ha de tenir :value kilobytes o menys.',
+        'string'  => 'El camp :attribute ha de tenir :value caràcters o menys.',
+        'array'   => 'El camp :attribute ha de tenir :value elements o menys.',
+    ],
+    'max'                  => [
+        'numeric' => 'El camp :attribute no pot ser més gran que :max.',
+        'file'    => 'El camp :attribute no pot tenir més de :max kilobytes.',
+        'string'  => 'El camp :attribute no pot tenir més de :max caràcters.',
+        'array'   => 'El camp :attribute no pot tenir més de :max elements.',
+    ],
+    'mimes'                => 'El camp :attribute ha de ser un fitxer del tipus: :values.',
+    'min'                  => [
+        'numeric' => 'El camp :attribute no pot ser més petit que :min.',
+        'file'    => 'El camp :attribute no pot tenir menys de :min kilobytes.',
+        'string'  => 'El camp :attribute no pot tenir menys de :min caràcters.',
+        'array'   => 'El camp :attribute no pot tenir menys de :min elements.',
+    ],
+    'not_in'               => 'El camp :attribute seleccionat no és vàlid.',
+    'not_regex'            => 'El format del camp :attribute no és vàlid.',
+    'numeric'              => 'El camp :attribute ha de ser un número.',
+    'regex'                => 'El format del camp :attribute no és vàlid.',
+    'required'             => 'El camp :attribute és obligatori.',
+    'required_if'          => 'El camp :attribute és obligatori quan :other és :value.',
+    'required_with'        => 'El camp :attribute és obligatori quan hi ha aquest valor: :values.',
+    'required_with_all'    => 'El camp :attribute és obligatori quan hi ha algun d\'aquests valors: :values.',
+    'required_without'     => 'El camp :attribute és obligatori quan no hi ha aquest valor: :values.',
+    'required_without_all' => 'El camp :attribute és obligatori quan no hi ha cap d\'aquests valors: :values.',
+    'same'                 => 'Els camps :attribute i :other han de coincidir.',
+    'safe_url'             => 'L\'enllaç proporcionat podria no ser segur.',
+    'size'                 => [
+        'numeric' => 'El camp :attribute ha de ser :size.',
+        'file'    => 'El camp :attribute ha de tenir :size kilobytes.',
+        'string'  => 'El camp :attribute ha de tenir :size caràcters.',
+        'array'   => 'El camp :attribute ha de contenir :size elements.',
+    ],
+    '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.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Cal la confirmació de la contrasenya',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index 23fce810812f8d2c2b7e5002e82f95952f7ed6f1..7178147025868db5a2878b1240c84f793e261da8 100644 (file)
@@ -6,43 +6,52 @@
 return [
 
     // Pages
-    'page_create'                 => 'vytvořená stránka',
+    'page_create'                 => 'vytvořil/a stránku',
     'page_create_notification'    => 'Stránka byla úspěšně vytvořena',
-    'page_update'                 => 'aktualizovaná stránka',
+    'page_update'                 => 'aktualizoval/a stránku',
     'page_update_notification'    => 'Stránka byla úspěšně aktualizována',
-    'page_delete'                 => 'smazaná stránka',
-    'page_delete_notification'    => 'Stránka byla úspěšně smazána',
-    'page_restore'                => 'renovovaná stránka',
-    'page_restore_notification'   => 'Stránka byla úspěšně renovována',
-    'page_move'                   => 'přesunutá stránka',
+    'page_delete'                 => 'odstranil/a stránku',
+    'page_delete_notification'    => 'Stránka byla odstraněna',
+    'page_restore'                => 'obnovil/a stránku',
+    'page_restore_notification'   => 'Stránka byla úspěšně obnovena',
+    'page_move'                   => 'přesunul/a stránku',
 
     // Chapters
-    'chapter_create'              => 'vytvořená kapitola',
+    'chapter_create'              => 'vytvořil/a kapitolu',
     'chapter_create_notification' => 'Kapitola byla úspěšně vytvořena',
-    'chapter_update'              => 'aktualizovaná kapitola',
+    'chapter_update'              => 'aktualizoval/a kapitolu',
     'chapter_update_notification' => 'Kapitola byla úspěšně aktualizována',
-    'chapter_delete'              => 'smazaná kapitola',
-    'chapter_delete_notification' => 'Kapitola byla úspěšně smazána',
-    'chapter_move'                => 'přesunutá kapitola',
+    'chapter_delete'              => 'odstranila/a kapitolu',
+    'chapter_delete_notification' => 'Kapitola byla odstraněna',
+    'chapter_move'                => 'přesunul/a kapitolu',
 
     // Books
-    'book_create'                 => 'vytvořená kniha',
-    'book_create_notification'    => 'Kniha byla úspěšně vytvořena',
-    'book_update'                 => 'aktualizovaná kniha',
-    'book_update_notification'    => 'Kniha byla úspěšně aktualizována',
-    'book_delete'                 => 'smazaná kniha',
-    'book_delete_notification'    => 'Kniha byla úspěšně smazána',
-    'book_sort'                   => 'seřazená kniha',
-    'book_sort_notification'      => 'Kniha byla úspěšně seřazena',
+    'book_create'                 => 'vytvořil/a knihu',
+    'book_create_notification'    => 'Kniha byla vytvořena',
+    'book_update'                 => 'aktualizoval/a knihu',
+    'book_update_notification'    => 'Kniha byla aktualizována',
+    'book_delete'                 => 'odstranil/a knihu',
+    'book_delete_notification'    => 'Kniha byla odstraněna',
+    'book_sort'                   => 'seřadil/a knihu',
+    'book_sort_notification'      => 'Kniha byla seřazena',
 
     // Bookshelves
-    'bookshelf_create'            => 'vytvořená knihovna',
-    'bookshelf_create_notification'    => 'Knihovna úspěšně vytvořena',
-    'bookshelf_update'                 => 'aktualizovaná knihovna',
+    'bookshelf_create'            => 'vytvořil/a knihovnu',
+    'bookshelf_create_notification'    => 'Knihovna byla úspěšně vytvořena',
+    'bookshelf_update'                 => 'aktualizoval/a knihovnu',
     'bookshelf_update_notification'    => 'Knihovna byla úspěšně aktualizována',
-    'bookshelf_delete'                 => 'smazaná knihovna',
-    'bookshelf_delete_notification'    => 'Knihovna byla úspěšně smazána',
+    'bookshelf_delete'                 => 'odstranil/a knihovnu',
+    'bookshelf_delete_notification'    => 'Knihovna byla odstraně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' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
 
     // Other
-    'commented_on'                => 'okomentováno v',
+    'commented_on'                => 'okomentoval/a',
+    'permissions_update'          => 'oprávnění upravena',
 ];
index 0e841686d5d52eaf5755099fa05b5e8d67ddb6a8..f8cdb77479d3da618dec296cb5eb484c0d4b73db 100644 (file)
 return [
 
     'failed' => 'Neplatné přihlašovací údaje.',
-    'throttle' => 'Příliš pokusů o přihlášení. Zkuste to prosím znovu za :seconds sekund.',
+    '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 přes :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' => 'Jméno účtu',
+    'username' => 'Uživatelské jméno',
     'email' => 'E-mail',
     'password' => 'Heslo',
-    'password_confirm' => 'Potvrdit heslo',
+    'password_confirm' => 'Potvrzení hesla',
     'password_hint' => 'Musí mít víc než 7 znaků',
-    'forgot_password' => 'Zapomněli jste heslo?',
-    'remember_me' => 'Neodhlašovat',
+    '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' => 'Máte už založený účet?',
+    'already_have_account' => 'Již máte účet?',
     '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íky za registraci!',
-    'register_confirm' => 'Zkontrolujte prosím váš email a klikněte na potvrzovací tlačítko pro dokončení registrace do :appName.',
+    '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 momentálně pozastaveny',
-    'registration_email_domain_invalid' => 'Registrace z této emailové domény nejsou povoleny.',
-    'register_success' => 'Díky za registraci! Jste registrovaní a přihlášení.',
+    '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' => 'Resetovat heslo',
-    'reset_password_send_instructions' => 'Zadejte vaší emailovou adresu a bude vám zaslán odkaz na resetování hesla.',
-    'reset_password_send_button' => 'Poslat odkaz pro reset hesla',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
-    'reset_password_success' => 'Vaše heslo bylo úspěšně resetováno.',
-    'email_reset_subject' => 'Reset hesla do :appName',
-    'email_reset_text' => 'Tento email jste obdrželi, protože jsme dostali žádost o resetování vašeho hesla k účtu v :appName.',
-    'email_reset_not_requested' => 'Pokud jste o reset vašeho hesla nežádali, prostě tento dopis smažte a je to.',
+    '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 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 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 vaši emailovou adresu pro :appName',
+    '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 funkčnost vaší emailové adresy kliknutím na tlačítko níže:',
-    'email_confirm_action' => 'Potvrdit emailovou adresu',
-    'email_confirm_send_error' => 'Potvrzení emailové adresy je vyžadováno, ale systém vám nedokázal odeslat email. Kontaktujte správce aby to dal do kupy a potvrzovací email vám dorazil.',
-    'email_confirm_success' => 'Vaše emailová adresa byla potvrzena!',
-    'email_confirm_resent' => 'Email s žádostí o potvrzení vaší emailové adresy byl odeslán. Podívejte se do příchozí pošty.',
+    '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_resent' => 'E-mail s potvrzením byl znovu odeslán. Zkontrolujte svou příchozí poštu.',
 
-    'email_not_confirmed' => 'Emailová adresa nebyla potvrzena',
-    'email_not_confirmed_text' => 'Vaše emailová adresa nebyla dosud potvrzena.',
-    'email_not_confirmed_click_link' => 'Klikněte na odkaz v emailu který jsme vám zaslali ihned po registraci.',
-    'email_not_confirmed_resend' => 'Pokud nemůžete nalézt email v příchozí poště, můžete si jej nechat poslat znovu pomocí formuláře níže.',
-    'email_not_confirmed_resend_button' => 'Znovu poslat email pro potvrzení emailové adresy',
+    'email_not_confirmed' => 'E-mailová adresa nebyla potvrzena',
+    'email_not_confirmed_text' => 'Vaše e-mailová adresa nebyla dosud potvrzena.',
+    'email_not_confirmed_click_link' => 'Klikněte prosím na odkaz v e-mailu, který byl odeslán krátce po registraci.',
+    'email_not_confirmed_resend' => 'Pokud nemůžete e-mail nalézt, můžete znovu odeslat potvrzovací e-mail odesláním níže uvedeného formuláře.',
+    'email_not_confirmed_resend_button' => 'Znovu odeslat potvrzovací e-mail',
 
     // User Invite
-    'user_invite_email_subject' => 'Byl jste pozván 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 tlačítko níže pro nastavení hesla k účtu a získání přístupu:',
-    'user_invite_email_action' => 'Nastavit heslo účtu',
+    '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 účtu',
     'user_invite_page_welcome' => 'Vítejte v :appName!',
-    'user_invite_page_text' => 'Chcete-li dokončit svůj účet a získat přístup, 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!'
+    'user_invite_success' => 'Heslo nastaveno, nyní máte přístup k :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.',
 ];
\ No newline at end of file
index 3586a61260c649518139d3801d09148b87046875..88821ecf9b9616962296d003f873e137cccc49fa 100644 (file)
@@ -5,75 +5,91 @@
 return [
 
     // Buttons
-    'cancel' => 'Storno',
+    'cancel' => 'Zrušit',
     'confirm' => 'Potvrdit',
     'back' => 'Zpět',
     'save' => 'Uložit',
     'continue' => 'Pokračovat',
-    'select' => 'Zvolit',
+    'select' => 'Vybrat',
     'toggle_all' => 'Přepnout vše',
     'more' => 'Více',
 
     // Form Labels
-    'name' => 'Jméno',
+    'name' => 'Název',
     'description' => 'Popis',
-    'role' => 'Funkce',
-    'cover_image' => 'Obrázek na přebal',
-    'cover_image_description' => 'Obrázek by měl být asi 440 × 250px.',
+    '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' => 'Pohled',
+    'view' => 'Zobrazit',
     'view_all' => 'Zobrazit vše',
     'create' => 'Vytvořit',
     'update' => 'Aktualizovat',
     'edit' => 'Upravit',
-    'sort' => 'Řadit',
+    'sort' => 'Seřadit',
     'move' => 'Přesunout',
     'copy' => 'Kopírovat',
     'reply' => 'Odpovědět',
-    'delete' => 'Smazat',
+    'delete' => 'Odstranit',
+    'delete_confirm' => 'Potvrdit odstranění',
     'search' => 'Hledat',
-    'search_clear' => 'Vyčistit hledání',
-    'reset' => 'Resetovat',
-    'remove' => 'Odstranit',
+    'search_clear' => 'Vymazat hledání',
+    'reset' => 'Obnovit',
+    'remove' => 'Odebrat',
     'add' => 'Přidat',
+    'configure' => 'Configure',
     'fullscreen' => 'Celá obrazovka',
+    'favourite' => 'Přidat do oblíbených',
+    'unfavourite' => 'Odebrat z oblíbených',
+    'next' => 'Další',
+    'previous' => 'Předchozí',
 
     // Sort Options
     'sort_options' => 'Možnosti řazení',
     'sort_direction_toggle' => 'Přepínač směru řazení',
     'sort_ascending' => 'Řadit vzestupně',
     'sort_descending' => 'Řadit sestupně',
-    'sort_name' => 'Jméno',
+    'sort_name' => 'Název',
+    'sort_default' => 'Výchozí',
     'sort_created_at' => 'Datum vytvoření',
     'sort_updated_at' => 'Datum aktualizace',
 
     // Misc
-    'deleted_user' => 'Smazaný uživatel',
+    'deleted_user' => 'Odstraněný uživatel',
     'no_activity' => 'Žádná aktivita k zobrazení',
-    'no_items' => 'Žádné položky nejsou k mání',
+    'no_items' => 'Žádné položky k dispozici',
     'back_to_top' => 'Zpět na začátek',
-    'toggle_details' => 'Ukázat detaily',
-    'toggle_thumbnails' => 'Ukázat náhledy',
-    'details' => 'Detaily',
-    'grid_view' => 'Zobrazit dlaždice',
-    'list_view' => 'Zobrazit seznam',
+    'skip_to_main_content' => 'Přeskočit na hlavní obsah',
+    'toggle_details' => 'Přepnout podrobnosti',
+    'toggle_thumbnails' => 'Přepnout náhledy',
+    'details' => 'Podrobnosti',
+    'grid_view' => 'Zobrazení mřížky',
+    'list_view' => 'Zobrazení seznamu',
     'default' => 'Výchozí',
     'breadcrumb' => 'Drobečková navigace',
 
     // Header
+    'header_menu_expand' => 'Rozbalit menu v záhlaví',
     'profile_menu' => 'Nabídka profilu',
-    'view_profile' => 'Ukázat profil',
+    'view_profile' => 'Zobrazit profil',
     'edit_profile' => 'Upravit profil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Tmavý režim',
+    'light_mode' => 'Světelný režim',
 
     // Layout tabs
-    'tab_info' => 'Info',
+    'tab_info' => 'Informace',
+    'tab_info_label' => 'Tab: Zobrazit podružné informace',
     'tab_content' => 'Obsah',
+    'tab_content_label' => 'Tab: Zobrazit hlavní obsah',
 
     // Email Content
-    'email_action_help' => 'Pokud se vám nedaří kliknout na tlačítko ":actionText", zkopírujte odkaz níže přímo do webového prohlížeče:',
+    '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:',
     'email_rights' => 'Všechna práva vyhrazena',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Zásady ochrany osobních údajů',
+    'terms_of_service' => 'Podmínky služby',
 ];
index a62914f1bc680c21ba9077eae4444dd6f7c9c540..20df62e36066092af0f12f53691ccd55a1f2a8bb 100644 (file)
@@ -5,29 +5,30 @@
 return [
 
     // Image Manager
-    'image_select' => 'Volba obrázku',
+    'image_select' => 'Výběr obrázku',
     'image_all' => 'Vše',
     'image_all_title' => 'Zobrazit všechny obrázky',
-    'image_book_title' => 'Zobrazit obrázky nahrané k této knize',
-    'image_page_title' => 'Zobrazit obrázky nahrané k této stránce',
+    'image_book_title' => 'Zobrazit obrázky nahrané do této knihy',
+    'image_page_title' => 'Zobrazit obrázky nahrané na tuto stránku',
     'image_search_hint' => 'Hledat podle názvu obrázku',
     'image_uploaded' => 'Nahráno :uploadedDate',
     'image_load_more' => 'Načíst další',
     'image_image_name' => 'Název obrázku',
-    'image_delete_used' => 'Tento obrázek je použit v následujících stránkách.',
-    'image_delete_confirm' => 'Stisknětě smazat ještě jednou pro potvrzení smazání tohoto 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' => 'Zvolte obrázek',
-    'image_dropzone' => 'Přetáhněte sem obrázky myší nebo sem klikněte pro vybrání souboru.',
-    'images_deleted' => 'Obrázky smazány',
+    '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ě smazán',
-    'image_upload_remove' => 'Odstranit',
+    '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
     'code_editor' => 'Upravit kód',
     'code_language' => 'Jazyk kódu',
     'code_content' => 'Obsah kódu',
+    'code_session_history' => 'Historie relace',
     'code_save' => 'Uložit kód',
 ];
index 579d49127b86f2a312f796aa806775b38c184920..97823a708cc5e268d4758b6dc1a298f90a5c6cc7 100644 (file)
@@ -11,230 +11,239 @@ return [
     'recently_updated_pages' => 'Nedávno aktualizované stránky',
     'recently_created_chapters' => 'Nedávno vytvořené kapitoly',
     'recently_created_books' => 'Nedávno vytvořené knihy',
-    'recently_created_shelves' => 'Recently Created Shelves',
+    'recently_created_shelves' => 'Nedávno vytvořené knihovny',
     'recently_update' => 'Nedávno aktualizované',
-    'recently_viewed' => 'Nedávno prohlížené',
-    'recent_activity' => 'Nedávné činnosti',
-    'create_now' => 'Vytvořte jí',
+    'recently_viewed' => 'Nedávno zobrazené',
+    'recent_activity' => 'Nedávné aktivity',
+    'create_now' => 'Vytvořit nyní',
     'revisions' => 'Revize',
-    'meta_revision' => 'Revize #:revisionCount',
+    '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',
-    'entity_select' => 'Volba prvku',
+    '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' => 'Naposledy navštívené',
-    'no_pages_viewed' => 'Zatím jste nic neshlédli',
-    'no_pages_recently_created' => 'Zatím nebyly vytvořeny žádné stránky',
-    'no_pages_recently_updated' => 'Zatím nebyly aktualizovány žádné stránky',
-    'export' => 'Export',
-    'export_html' => 'Všeobjímající HTML',
+    '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' => 'Žádné nedávno vytvořené stránky',
+    'no_pages_recently_updated' => 'Žádné nedávno aktualizované stránky',
+    'export' => 'Exportovat',
+    'export_html' => 'HTML stránka s celým obsahem',
     'export_pdf' => 'PDF dokument',
-    'export_text' => 'Čistý text (txt)',
+    'export_text' => 'Textový soubor',
+    'export_md' => 'Markdown',
 
     // Permissions and restrictions
-    'permissions' => 'Práva',
-    'permissions_intro' => 'Zaškrtnutím překryjete práva v uživatelských rolích nastavením níže.',
-    'permissions_enable' => 'Zapnout vlastní práva',
-    'permissions_save' => 'Uložit práva',
+    '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' => '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_clear' => 'Vyčistit hledání',
-    'search_no_pages' => 'Žádná stránka neodpovídá hledanému výrazu',
+    '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',
     'search_more' => 'Další výsledky',
-    'search_filters' => 'Filtry hledání',
+    'search_advanced' => 'Rozšířené hledání',
+    'search_terms' => 'Hledané výrazy',
     'search_content_type' => 'Typ obsahu',
-    'search_exact_matches' => 'Musí obsahovat',
-    'search_tags' => 'Hledat štítky (tagy)',
-    'search_options' => 'Volby',
-    'search_viewed_by_me' => 'Shlédnuto mnou',
-    'search_not_viewed_by_me' => 'Neshlédnuto mnou',
-    'search_permissions_set' => 'Sada práv',
+    'search_exact_matches' => 'Přesné shody',
+    'search_tags' => 'Hledat štítky',
+    'search_options' => 'Možnosti',
+    'search_viewed_by_me' => 'Zobrazeno mnou',
+    'search_not_viewed_by_me' => 'Nezobrazeno mnou',
+    'search_permissions_set' => 'Sada oprávnění',
     'search_created_by_me' => 'Vytvořeno mnou',
-    'search_updated_by_me' => 'Aktualizováno',
-    'search_date_options' => 'Volby datumu',
+    'search_updated_by_me' => 'Aktualizováno mnou',
+    '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',
     'search_created_before' => 'Vytvořeno před',
     'search_created_after' => 'Vytvořeno po',
-    'search_set_date' => 'Datum',
-    'search_update' => 'Hledat znovu',
+    'search_set_date' => 'Nastavit datum',
+    'search_update' => 'Aktualizovat hledání',
 
     // Shelves
     'shelf' => 'Knihovna',
     'shelves' => 'Knihovny',
-    'x_shelves' => ':count Shelf|:count Shelves',
+    'x_shelves' => '{0}:count knihoven|{1}:count knihovna|[2,4]:count knihovny|[5,*]:count knihoven',
     'shelves_long' => 'Knihovny',
-    'shelves_empty' => 'Žádné knihovny nebyly vytvořeny',
+    'shelves_empty' => 'Nebyly vytvořeny žádné knihovny',
     'shelves_create' => 'Vytvořit novou knihovnu',
     'shelves_popular' => 'Populární knihovny',
     'shelves_new' => 'Nové knihovny',
-    'shelves_new_action' => 'New Shelf',
+    'shelves_new_action' => 'Nová Knihovna',
     'shelves_popular_empty' => 'Nejpopulárnější knihovny se objeví zde.',
-    'shelves_new_empty' => 'Nejnovější knihovny se objeví zde.',
+    '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' => 'Smazat knihovnu',
-    'shelves_delete_named' => 'Smazat knihovnu :name',
-    'shelves_delete_explain' => "Chystáte se smazat knihovnu ':name'. Knihy v ní obsažené zůstanou zachovány.",
-    'shelves_delete_confirmation' => 'Opravdu chcete smazat tuto knihovnu?',
-    'shelves_permissions' => 'Práva knihovny',
-    'shelves_permissions_updated' => 'Práva knihovny byla aktualizována',
-    'shelves_permissions_active' => 'Účinná práva knihovny',
-    'shelves_copy_permissions_to_books' => 'Přenést práva na knihy',
-    'shelves_copy_permissions' => 'Zkopírovat práva',
-    'shelves_copy_permissions_explain' => 'Práva knihovny budou aplikována na všechny knihy v ní obsažené. Před použitím se ujistěte, že jste uložili změny práv knihovny.',
-    'shelves_copy_permission_success' => 'Práva knihovny přenesena na knihy (celkem :count)',
+    'shelves_delete' => 'Odstranit knihovnu',
+    'shelves_delete_named' => 'Odstranit knihovnu :name',
+    '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 byla aktivována',
+    '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' => '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í 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
     'book' => 'Kniha',
     'books' => 'Knihy',
-    'x_books' => ':count Kniha|:count Knihy|:count Knihy|:count Knihy|:count Knih',
-    'books_empty' => 'Žádné knihy nebyly vytvořeny',
-    'books_popular' => 'Populární knihy',
+    'x_books' => '{0}:count knih|{1}:count kniha|[2,4]:count knihy|[5,*]:count knih',
+    'books_empty' => 'Nebyly vytvořeny žádné knihy',
+    'books_popular' => 'Oblíbené knihy',
     'books_recent' => 'Nedávné knihy',
     'books_new' => 'Nové knihy',
-    'books_new_action' => 'New Book',
-    'books_popular_empty' => 'Zde budou zobrazeny nejpopulárnější knihy.',
-    'books_new_empty' => 'Zde budou zobrazeny nově vytvořené knihy.',
+    'books_new_action' => 'Nová kniha',
+    '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' => 'Smazat knihu',
-    'books_delete_named' => 'Smazat knihu :bookName',
-    'books_delete_explain' => 'Kniha \':bookName\' bude smazána. Všechny její stránky a kapitoly budou taktéž smazány.',
-    'books_delete_confirmation' => 'Opravdu chcete tuto knihu smazat.',
+    'books_delete' => 'Odstranit knihu',
+    'books_delete_named' => 'Odstranit knihu :bookName',
+    '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' => 'Práva knihy',
-    'books_permissions_updated' => 'Práva knihy upravena',
-    'books_empty_contents' => 'V této knize nebyly vytvořeny žádné stránky ani kapitoly.',
+    'books_permissions' => 'Oprávnění knihy',
+    '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 tuto knihu',
+    'books_empty_sort_current_book' => 'Seřadit aktuální knihu',
     'books_empty_add_chapter' => 'Přidat kapitolu',
-    'books_permissions_active' => 'Účinná práva knihy',
+    'books_permissions_active' => 'Oprávnění knihy byla aktivována',
     'books_search_this' => 'Prohledat tuto knihu',
-    'books_navigation' => 'Obsah knihy',
+    'books_navigation' => 'Navigace knihy',
     'books_sort' => 'Seřadit obsah knihy',
     'books_sort_named' => 'Seřadit 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_show_other' => 'Ukázat ostatní knihy',
+    '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 jako první',
+    'books_sort_chapters_last' => 'Kapitoly jako poslední',
+    'books_sort_show_other' => 'Zobrazit ostatní knihy',
     'books_sort_save' => 'Uložit nové pořadí',
 
     // Chapters
     'chapter' => 'Kapitola',
     'chapters' => 'Kapitoly',
-    'x_chapters' => ':count kapitola|:count kapitoly|:count kapitoly|:count kapitoly|:count kapitol',
+    'x_chapters' => '{0}:count Kapitol|{1}:count Kapitola|[2,4]:count Kapitoly|[5,*]:count Kapitol',
     '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' => 'Kapitola \':chapterName\' bude smazána. Všechny stránky v ní obsažené budou přesunuty přímo pod samotnou knihu.',
-    '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_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
     'page' => 'Stránka',
     'pages' => 'Stránky',
-    'x_pages' => ':count strana|:count strany|:count strany|:count strany|:count stran',
+    'x_pages' => '{0}:count Stran|{1}:count Strana|[2,4]:count Strany|[5,*]:count Stran',
     'pages_popular' => 'Populární stránky',
     '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_draft' => 'Smazat koncept stránky',
-    'pages_delete_success' => 'Stránka smazána',
-    'pages_delete_draft_success' => 'Koncept stránky smazán',
-    'pages_delete_confirm' => 'Opravdu chcete tuto stránku smazat?',
-    'pages_delete_draft_confirm' => 'Opravdu chcete tento koncept stránky smazat?',
+    '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',
+    'pages_delete_confirm' => 'Opravdu chcete odstranit tuto stránku?',
+    'pages_delete_draft_confirm' => 'Opravdu chcete odstranit tento koncept stránky?',
     'pages_editing_named' => 'Úpravy stránky :pageName',
-    'pages_edit_draft_options' => 'Draft Options',
+    'pages_edit_draft_options' => 'Možnosti konceptu',
     'pages_edit_save_draft' => 'Uložit koncept',
     'pages_edit_draft' => 'Upravit koncept stránky',
-    'pages_editing_draft' => 'Úpravy konceptu',
+    'pages_editing_draft' => 'Úprava konceptu',
     'pages_editing_page' => 'Úpravy stránky',
     'pages_edit_draft_save_at' => 'Koncept uložen v ',
-    'pages_edit_delete_draft' => 'Smazat koncept',
+    'pages_edit_delete_draft' => 'Odstranit koncept',
     'pages_edit_discard_draft' => 'Zahodit koncept',
-    'pages_edit_set_changelog' => 'Zadat komentář ke změnám',
-    'pages_edit_enter_changelog_desc' => 'Zadejte stručný popis změn, které jste provedli.',
-    'pages_edit_enter_changelog' => 'Vložit komentáře ke změnám',
+    'pages_edit_set_changelog' => 'Nastavit protokol změn',
+    'pages_edit_enter_changelog_desc' => 'Zadejte stručný popis změn, které jste provedli',
+    'pages_edit_enter_changelog' => 'Zadejte protokol změn',
     'pages_save' => 'Uložit stránku',
     'pages_title' => 'Nadpis stránky',
     'pages_name' => 'Název stránky',
     'pages_md_editor' => 'Editor',
     'pages_md_preview' => 'Náhled',
     'pages_md_insert_image' => 'Vložit obrázek',
-    'pages_md_insert_link' => 'Vložit odkaz na prvek',
+    'pages_md_insert_link' => 'Vložit odkaz na entitu',
     'pages_md_insert_drawing' => 'Vložit kresbu',
-    'pages_not_in_chapter' => 'Stránka není součástí žádné kapitoly',
+    'pages_not_in_chapter' => 'Stránka není v kapitole',
     'pages_move' => 'Přesunout stránku',
     '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_permissions' => 'Práva stránky',
-    'pages_permissions_success' => 'Práva stránky aktualizová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 byla aktualizována',
     'pages_revision' => 'Revize',
     'pages_revisions' => 'Revize stránky',
-    'pages_revisions_named' => 'Revize stránky :pageName',
-    'pages_revision_named' => 'Revize stránky :pageName',
+    'pages_revisions_named' => 'Revize stránky pro :pageName',
+    'pages_revision_named' => 'Revize stránky pro :pageName',
+    '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_numbered' => 'Revision #:id',
-    'pages_revisions_numbered_changes' => 'Revision #:id Changes',
-    'pages_revisions_changelog' => 'Komentáře změn',
+    'pages_revisions_number' => 'Č. ',
+    'pages_revisions_numbered' => 'Revize č. :id',
+    'pages_revisions_numbered_changes' => 'Změny revize č. :id',
+    'pages_revisions_changelog' => 'Protokol změn',
     'pages_revisions_changes' => 'Změny',
     'pages_revisions_current' => 'Aktuální verze',
     'pages_revisions_preview' => 'Náhled',
-    'pages_revisions_restore' => 'Renovovat',
+    'pages_revisions_restore' => 'Obnovit',
     'pages_revisions_none' => 'Tato stránka nemá žádné revize',
-    'pages_copy_link' => 'Zkopírovat odkaz',
+    '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_edit_active' => [
         'start_a' => 'Uživatelé začali upravovat tuto stránku (celkem :count)',
-        'start_b' => 'Uživatel :userName začal upravovat tuto stránku',
+        'start_b' => ':userName začal/a upravovat tuto stránku',
         'time_a' => 'od doby, kdy byla tato stránky naposledy aktualizována',
         'time_b' => 'v posledních minutách (:minCount min.)',
         'message' => ':start :time. Dávejte pozor abyste nepřepsali změny ostatním!',
     ],
     'pages_draft_discarded' => 'Koncept zahozen. Editor nyní obsahuje aktuální verzi stránky.',
     'pages_specific' => 'Konkrétní stránka',
-    'pages_is_template' => 'Page Template',
+    'pages_is_template' => 'Šablona stránky',
 
     // Editor Sidebar
     'page_tags' => 'Štítky stránky',
@@ -243,11 +252,11 @@ return [
     'shelf_tags' => 'Štítky knihovny',
     'tag' => 'Štítek',
     'tags' =>  'Štítky',
-    'tag_name' =>  'Tag Name',
-    'tag_value' => 'Hodnota Å títku (volitelné)',
+    'tag_name' =>  'Název štítku',
+    'tag_value' => 'Hodnota Å¡títku (volitelné)',
     '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' => 'Remove this tag',
+    'tags_remove' => 'Odstranit tento štítek',
     '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í.',
@@ -255,44 +264,45 @@ return [
     'attachments_upload' => 'Nahrát soubor',
     'attachments_link' => 'Připojit odkaz',
     'attachments_set_link' => 'Nastavit odkaz',
-    'attachments_delete_confirm' => 'Stiskněte smazat znovu pro potvrzení smazání.',
-    '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_delete' => 'Jste si jisti, že chcete odstranit tuto přílohu?',
+    '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',
     'attachments_link_url' => 'Odkaz na soubor',
     'attachments_link_url_hint' => 'URL stránky nebo souboru',
     'attach' => 'Připojit',
+    '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',
-    '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',
+    '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.',
+    'templates_replace_content' => 'Nahradit obsah stránky',
+    'templates_append_content' => 'Připojit za obsah stránky',
+    'templates_prepend_content' => 'Připojit před obsah stránky',
 
     // Profile View
     'profile_user_for_x' => 'Uživatelem již :time',
     'profile_created_content' => 'Vytvořený obsah',
-    'profile_not_created_pages' => ':userName nevytvoÅ\99il/a Å¾Ã¡dný obsah',
+    'profile_not_created_pages' => ':userName nevytvoÅ\99il/a Å¾Ã¡dné stránky',
     'profile_not_created_chapters' => ':userName nevytvořil/a žádné kapitoly',
     'profile_not_created_books' => ':userName nevytvořil/a žádné knihy',
-    'profile_not_created_shelves' => ':userName has not created any shelves',
+    'profile_not_created_shelves' => ':userName nevytvořil/a žádné knihovny',
 
     // Comments
     '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...',
@@ -300,15 +310,15 @@ 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_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
-    'revision_delete_success' => 'Revize smazána',
-    'revision_cannot_delete_latest' => 'Nelze smazat poslední revizi.'
-];
\ No newline at end of file
+    '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 odstraněna',
+    'revision_cannot_delete_latest' => 'Nelze odstranit poslední revizi.'
+];
index f3b87fc02f178db5eaf272570e80f99e3987eb1b..c948cafd1095aa138a9ffc8e9b9265517a38a6e2 100644 (file)
@@ -5,24 +5,24 @@
 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_awaiting' => 'The email address for the account in use needs to be confirmed',
+    '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',
     'ldap_extension_not_installed' => 'Není nainstalováno rozšíření LDAP pro PHP',
     'ldap_cannot_connect' => 'Nelze se připojit k adresáři LDAP. Prvotní připojení selhalo.',
-    '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',
+    'saml_already_logged_in' => 'Již jste přihlášeni',
+    'saml_user_not_registered' => 'Uživatel :name není registrován a automatická registrace je zakázána',
+    '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',
     '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.',
@@ -33,7 +33,7 @@ return [
     'social_account_register_instructions' => 'Pokud ještě nemáte náš účet, můžete se zaregistrovat pomocí vašeho účtu na :socialAccount.',
     'social_driver_not_found' => 'Doplněk pro tohoto správce identity nebyl nalezen.',
     'social_driver_not_configured' => 'Nastavení vašeho účtu na :socialAccount není správné. :socialAccount musí mít vaše svolení pro naší aplikaci vás přihlásit.',
-    'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
+    'invite_token_expired' => 'Odkaz v pozvánce již bohužel vypršel. Namísto toho ale můžete zkusit resetovat heslo do Vašeho účtu.',
 
     // System
     'path_not_writable' => 'Nelze zapisovat na cestu k souboru :filePath. Zajistěte aby se dalo nahrávat na server.',
@@ -46,12 +46,11 @@ return [
     'file_upload_timeout' => 'Nahrávání souboru trvalo příliš dlouho a tak bylo ukončeno.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Došlo ke zmatení stránky během nahrávání přílohy.',
     'attachment_not_found' => 'Příloha nenalezena',
 
     // 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',
@@ -61,43 +60,46 @@ 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_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
+    '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' => '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 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',
+    'api_user_no_api_permission' => 'Vlastník použitého API tokenu nemá oprávnění provádět API volání',
+    'api_user_token_expired' => 'Platnost autorizačního tokenu vypršela',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'Při posílání testovacího e-mailu nastala chyba:',
 
 ];
index 6281ff05845c0668cbbd09b92900b2220c14f59e..eb0beabb9898819cc73954014fc5e27d3912f570 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'previous' => '\'&laquo; P',
-    'next'     => 'Dal',
+    'previous' => '&laquo; Předchozí',
+    'next'     => 'Další &raquo;',
 
 ];
index 368523630cf6213317324e8f082be82dc6f9b8a1..40b12b6070fe64cc7fa4f623ab83abadc3b13c4f 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => 'Heslo musí být alespoň 6 znaků dlouhé a shodovat se v obou polích.',
-    'user' => "Nemůžeme najít uživatele se zadanou emailovou adresou.",
-    'token' => 'The password reset token is invalid for this email address.',
-    'sent' => 'Poslali jsme vám odkaz pro reset hesla!',
-    'reset' => 'Vaše heslo bylo resetováno!',
+    '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 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 a5b60ba583bdc8a2fa9bffd98e4300286dd376a0..36d8bc0ecc861c654a07f462483a21856bf3fb74 100644 (file)
@@ -9,175 +9,229 @@ return [
     // Common Messages
     'settings' => 'Nastavení',
     'settings_save' => 'Uložit nastavení',
-    'settings_save_success' => 'Nastavení bylo uloženo',
+    'settings_save_success' => 'Nastavení uloženo',
 
     // App Settings
-    'app_customization' => 'Customization',
-    'app_features_security' => 'Features & Security',
+    'app_customization' => 'Přizpůsobení',
+    'app_features_security' => 'Funkce a zabezpečení',
     'app_name' => 'Název aplikace',
-    'app_name_desc' => 'Název se bude zobrazovat v záhlaví této aplikace a v odesílaných emailech.',
-    'app_name_header' => 'Zobrazovát název aplikace v záhlaví?',
-    '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_name_desc' => 'Název se bude zobrazovat v záhlaví této aplikace a v e-mailech odesílaných systémem.',
+    'app_name_header' => 'Zobrazovat název aplikace v záhlaví',
+    'app_public_access' => 'Veřejný přístup',
+    'app_public_access_desc' => 'Zapnutím této volby umožníte nepřihlášeným návštěvníkům přístup k Vašemu obsahu v BookStack aplikaci.',
+    'app_public_access_desc_guest' => 'Přístup pro nepřihlášené návštěvníky je možné nastavit přes uživatele "Guest".',
+    'app_public_access_toggle' => 'Povolit veřejný přístup',
     'app_public_viewing' => 'Povolit prohlížení veřejností?',
-    'app_secure_images' => 'Nahrávat obrázky neveřejně a zabezpečeně?',
-    'app_secure_images_toggle' => 'Enable higher security image uploads',
-    'app_secure_images_desc' => 'Z výkonnostních důvodů jsou všechny obrázky veřejné. Tato volba přidá do adresy obrázku náhodné číslo, aby nikdo neodhadnul adresu obrázku. Zajistěte ať adresáře nikomu nezobrazují seznam souborů.',
+    'app_secure_images' => 'Nahrávat obrázky neveřejně a zabezpečeně',
+    'app_secure_images_toggle' => 'Zapnout bezpečnější nahrávání obrázků',
+    'app_secure_images_desc' => 'Z výkonnostních důvodů jsou všechny obrázky veřejně dostupné. Tato volba přidá do adresy obrázku náhodný řetězec, aby nikdo neodhadnul adresu obrázku. Ujistěte se, že server nezobrazuje v adresáři seznam souborů, což by přístup k přístup opět otevřelo.',
     'app_editor' => 'Editor stránek',
     'app_editor_desc' => 'Zvolte který editor budou užívat všichni uživatelé k úpravě stránek.',
-    'app_custom_html' => 'Vlastní HTML kód pro sekci hlavičky (<head>).',
+    'app_custom_html' => 'Vlastní obsah hlavičky HTML',
     'app_custom_html_desc' => 'Cokoliv sem napíšete bude přidáno na konec sekce <head> v každém místě této aplikace. To se hodí pro přidávání nebo změnu CSS stylů nebo přidání kódu pro analýzu používání (např.: google analytics.).',
-    'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
+    'app_custom_html_disabled_notice' => 'Na této stránce nastavení je zakázán vlastní obsah HTML hlavičky, aby bylo zajištěno, že bude možné vrátit případnou problematickou úpravu.',
     'app_logo' => 'Logo aplikace',
-    'app_logo_desc' => 'Obrázek by měl mít 43 pixelů na výšku. <br>Větší obrázky zmenšíme na tuto velikost.',
+    'app_logo_desc' => 'Tento obrázek by měl mít výšku 43px. <br>Větší obrázky zmenšíme na tuto velikost.',
     'app_primary_color' => 'Hlavní barva aplikace',
-    'app_primary_color_desc' => 'Zápis by měl být hexa (#aabbcc). <br>Pro základní barvu nechte pole prázdné.',
+    'app_primary_color_desc' => 'Nastaví hlavní barvu aplikace včetně panelů, tlačítek a odkazů.',
     'app_homepage' => 'Úvodní stránka aplikace',
-    'app_homepage_desc' => 'Zvolte pohled který se objeví jako úvodní stránka po přihlášení. Pokud zvolíte stránku, její specifická oprávnění budou ignorována (výjimka z výjimky 😜).',
+    '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_disable_comments' => 'Zakázání komentářů',
-    'app_disable_comments_toggle' => 'Disable comments',
-    'app_disable_comments_desc' => 'Zakáže komentáře napříč všemi stránkami. Existující komentáře se přestanou zobrazovat.',
+    '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.',
 
     // 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' => 'Barvy obsahu',
+    'content_colors_desc' => 'Nastaví barvy pro všechny prvky v organizační struktuře stránky. Pro lepší čitelnost doporučujeme zvolit barvy s podobným jasem, jakou mají výchozí barvy.',
+    'bookshelf_color' => 'Barva knihovny',
+    'book_color' => 'Barva knihy',
+    'chapter_color' => 'Barva kapitoly',
+    'page_color' => 'Barva stránky',
+    'page_draft_color' => 'Barva návrhu stránky',
 
     // Registration Settings
     'reg_settings' => 'Nastavení registrace',
-    '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' => 'Povolení registrace',
+    'reg_enable_toggle' => 'Povolit registrace',
+    'reg_enable_desc' => 'Pokud jsou povoleny registrace, bude se uživatel moci sám registrovat jako uživatel aplikace. Po registraci dostane jednu výchozí uživatelskou roli.',
     'reg_default_role' => 'Role přiřazená po registraci',
-    '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' => 'Pokud zapnete omezení emailové domény, tak bude ověřování emailové adresy vyžadováno vždy.',
+    'reg_enable_external_warning' => 'Pokud je povolené externí ověřování přes LDAP nebo SAML, je výše uvedená možnost ignorována. Uživatelský účet budou automaticky vytvořen i neexistujícímu uživateli, jakmile se úspěšně přihlásí přes použitý externí přihlašovací systém.',
+    'reg_email_confirmation' => 'Ověření e-mailu',
+    'reg_email_confirmation_toggle' => 'Vyžadovat ověření e-mailu',
+    'reg_confirm_email_desc' => 'Pokud je zapnuté Omezení registrace podle domény, bude e-mail ověřován vždy a tato volba bude ignorována.',
     'reg_confirm_restrict_domain' => 'Omezit registraci podle domény',
-    'reg_confirm_restrict_domain_desc' => 'Zadejte emailové domény, kterým bude povolena registrace uživatelů. Oddělujete čárkou. Uživatelům bude odeslán email s odkazem pro potvrzení vlastnictví emailové adresy. Bez potvrzení nebudou moci aplikaci používat. <br> Pozn.: Uživatelé si mohou emailovou adresu změnit po úspěšné registraci.',
-    'reg_confirm_restrict_domain_placeholder' => 'Žádná omezení nebyla nastvena',
+    'reg_confirm_restrict_domain_desc' => 'Zadejte seznam e-mailových domén oddělených čárkami, na které chcete registraci omezit. Registrujícímu se uživateli bude zaslán e-mail, aby ověřil svoji e-mailovou adresu před tím, než mu bude přístup do aplikace povolen. <br> Upozorňujeme, že po úspěšné registraci může uživatel svoji e-mailovou adresu změnit.',
+    'reg_confirm_restrict_domain_placeholder' => 'Žádná omezení nebyla nastavena',
 
     // 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_image_cleanup_ignore_revisions' => 'Ignorovat obrázky v revizích',
+    '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' => '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_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.',
+    'maint_send_test_email_run' => 'Odeslat zkušební e-mail',
+    'maint_send_test_email_success' => 'E-mail odeslán na :address',
+    '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' => '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' => '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',
+    'audit_desc' => 'Tento protokol auditu zobrazuje seznam činností zaznamenaných v systému. Tento seznam není filtrován na rozdíl od podobných seznamů aktivit v systému, kde jsou použity filtry podle oprávnění.',
+    'audit_event_filter' => 'Filtr událostí',
+    'audit_event_filter_no_filter' => 'Bez filtru',
+    'audit_deleted_item' => 'Odstraněná položka',
+    'audit_deleted_item_name' => 'Jméno: :name',
+    'audit_table_user' => 'Uživatel',
+    'audit_table_event' => 'Událost',
+    'audit_table_related' => 'Související položka nebo detail',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Datum aktivity',
+    'audit_date_from' => 'Časový rozsah od',
+    'audit_date_to' => 'Časový rozsah do',
 
     // Role Settings
     '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' => 'Requires Multi-Factor Authentication',
     '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 úživatelů',
+    'role_manage_users' => 'Správa uživatelů',
     'role_manage_roles' => 'Správa rolí a jejich práv',
     'role_manage_entity_permissions' => 'Správa práv všech knih, kapitol a stránek',
     'role_manage_own_entity_permissions' => 'Správa práv vlastních knih, kapitol a stránek',
-    'role_manage_page_templates' => 'Manage page templates',
-    'role_access_api' => 'Access system API',
+    '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_asset' => 'Práva děl',
-    'role_asset_desc' => 'Tato práva řídí přístup k dílům v rámci systému. Specifická práva na knihách, kapitolách a stránkách překryjí tato nastavení.',
+    'role_export_content' => 'Export content',
+    'role_asset' => 'Obsahová oprávnění',
+    'roles_system_warning' => 'Berte na vědomí, že přístup k některému ze tří výše uvedených oprávnění může uživateli umožnit změnit svá vlastní oprávnění nebo oprávnění ostatních uživatelů v systému. Přiřazujte role s těmito oprávněními pouze důvěryhodným uživatelům.',
+    'role_asset_desc' => 'Tato oprávnění řídí přístup k obsahu napříč systémem. Specifická oprávnění na knihách, kapitolách a stránkách převáží tato nastavení.',
     'role_asset_admins' => 'Administrátoři automaticky dostávají přístup k veškerému obsahu, ale tyto volby mohou ukázat nebo skrýt volby v uživatelském rozhraní.',
     'role_all' => 'Vše',
     'role_own' => 'Vlastní',
-    'role_controlled_by_asset' => 'Řídí se dílem do kterého jsou nahrávány',
-    'role_save' => 'Uloži roli',
-    'role_update_success' => 'Role úspěšně aktualizována',
+    'role_controlled_by_asset' => 'Řídí se obsahem, do kterého jsou nahrávány',
+    'role_save' => 'Uložit roli',
+    'role_update_success' => 'Role byla aktualizována',
     'role_users' => 'Uživatelé mající tuto roli',
-    'role_users_none' => 'Žádný uživatel nemá tuto roli.',
+    'role_users_none' => 'Žádný uživatel nemá tuto roli',
 
     // Users
     'users' => 'Uživatelé',
     'user_profile' => 'Profil uživatele',
     'users_add_new' => 'Přidat nového uživatele',
     'users_search' => 'Vyhledávání uživatelů',
-    '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á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' => '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' => 'Přihlašovací identifikátory třetích stran',
-    'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
-    'users_password_warning' => 'Vyplňujte pouze v případě, že chcete heslo změnit:',
-    'users_system_public' => 'Symbolizuje libovolného veřejného návštěvníka, který navštívil vaší aplikaci. Nelze ho použít k přihlášení ale je přiřazen automaticky veřejnosti.',
+    '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_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_named' => 'Smazat uživatele :userName',
-    'users_delete_warning' => 'Uživatel \':userName\' bude úplně smazán ze systému.',
+    'users_delete_named' => 'Odstranit uživatele :userName',
+    'users_delete_warning' => 'Uživatel \':userName\' bude zcela odstraněn ze systému.',
     'users_delete_confirm' => 'Opravdu chcete tohoto uživatele smazat?',
-    'users_delete_success' => 'Uživatel byl úspěšně smazán',
+    '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' => 'Uživatelský obrázek',
-    'users_avatar_desc' => 'Obrázek by měl být čtverec 256 pixelů široký. Bude oříznut do kruhu.',
-    'users_preferred_language' => 'Upřednostňovaný 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_social_accounts' => 'Přidružené účty ze sociálních sítí',
-    '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í. Zrušení přidružení zde neznamená, že tato aplikace pozbude 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 vašem profilu na dané sociální síti.',
-    'users_social_connect' => 'Přidružit účet',
-    'users_social_disconnect' => 'Zrušit přidružení',
-    'users_social_connected' => 'Účet :socialAccount byl úspěšně přidružen k vašemu profilu.',
-    'users_social_disconnected' => 'Přidružení účtu :socialAccount k vašemu profilu bylo úspěšně zrušeno.',
-    '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_avatar' => 'Obrázek uživatele',
+    '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 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_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',
-    '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' => '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 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' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
+    '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' => '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_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ě 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',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 13a8f790fc058856cbad7b2e1cb1c585815675d5..ea7eebdf988d5a2d6c2cd7300f6bca16be1f099b 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'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute musí být datum před :date.',
     'between'              => [
         'numeric' => ':attribute musí být hodnota mezi :min a :max.',
@@ -30,7 +31,7 @@ return [
     'digits'               => ':attribute musí být :digits pozic dlouhé.',
     'digits_between'       => ':attribute musí být dlouhé nejméně :min a nejvíce :max pozic.',
     'email'                => ':attribute není platný formát.',
-    'ends_with' => 'The :attribute must end with one of the following: :values',
+    'ends_with' => ':attribute musí končit jednou z následujících hodnot: :values',
     'filled'               => ':attribute musí být vyplněno.',
     'gt'                   => [
         'numeric' => ':attribute musí být větší než :value.',
@@ -46,7 +47,7 @@ return [
     ],
     'exists'               => 'Zvolená hodnota pro :attribute není platná.',
     'image'                => ':attribute musí být obrázek.',
-    'image_extension'      => 'The :attribute must have a valid & supported image extension.',
+    'image_extension'      => ':attribute musí mít platné a podporované rozšíření obrázku.',
     'in'                   => 'Zvolená hodnota pro :attribute je neplatná.',
     'integer'              => ':attribute musí být celé číslo.',
     'ip'                   => ':attribute musí být platnou IP adresou.',
@@ -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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute musí být delší než :min znaků.',
         'array'   => ':attribute musí obsahovat více než :min prvků.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
     'not_in'               => 'Zvolená hodnota pro :attribute je neplatná.',
     'not_regex'            => ':attribute musí být regulární výraz.',
     'numeric'              => ':attribute musí být číslo.',
@@ -90,6 +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'             => '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'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attribute musí být unikátní.',
     'url'                  => 'Formát :attribute je neplatný.',
     'uploaded'             => 'Nahrávání :attribute se nezdařilo.',
@@ -105,7 +107,7 @@ return [
     // Custom validation lines
     'custom' => [
         'password-confirm' => [
-            'required_with' => 'Password confirmation required',
+            'required_with' => 'Je nutné potvrdit heslo',
         ],
     ],
 
index a72e9210bb15fc25669725f698e77933a30759ba..23c13b2d8444d0c6770df52f1b6503c060947ad0 100644 (file)
@@ -20,7 +20,7 @@ return [
     'chapter_create'              => 'oprettede kapitel',
     'chapter_create_notification' => 'Kapitel blev oprettet',
     'chapter_update'              => 'opdaterede kapitel',
-    'chapter_update_notification' => 'Kapitel blev opdateret',
+    'chapter_update_notification' => 'Kapitlet blev opdateret',
     'chapter_delete'              => 'slettede kapitel',
     'chapter_delete_notification' => 'Kapitel blev slettet',
     'chapter_move'                => 'flyttede kapitel',
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'slettede bogreol',
     'bookshelf_delete_notification'    => 'Bogreolen blev opdateret',
 
+    // 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',
+
     // Other
     'commented_on'                => 'kommenterede til',
+    'permissions_update'          => 'Tilladelser opdateret',
 ];
index a78f5c1c20293437e2f8d58314effdfab3cc7f97..8c9d86ea69c6706419992a35312abe8700abb456 100644 (file)
@@ -6,8 +6,8 @@
  */
 return [
 
-    'failed' => 'Det indtastede stemmer ikke overens med vores registrering.',
-    'throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds seconds.',
+    'failed' => 'Dee indtastede brugeroplysninger stemmer ikke overens med vores registreringer.',
+    'throttle' => 'For mange mislykkede loginforsøg. Prøv igen om :seconds sekunder.',
 
     // Login & Register
     'sign_up' => 'Registrér',
@@ -21,11 +21,11 @@ return [
     'email' => 'E-mail',
     'password' => 'Adgangskode',
     'password_confirm' => 'Bekræft adgangskode',
-    'password_hint' => 'Skal være på mindst 8 karakterer',
+    'password_hint' => 'Skal være på mindst 7 karakterer',
     'forgot_password' => 'Glemt Adgangskode?',
-    'remember_me' => 'Husk Mig',
+    'remember_me' => 'Husk mig',
     'ldap_email_hint' => 'Angiv venligst din kontos e-mail.',
-    'create_account' => 'Opret Konto',
+    'create_account' => 'Opret konto',
     'already_have_account' => 'Har du allerede en konto?',
     'dont_have_account' => 'Har du ikke en konto?',
     'social_login' => 'Social Log ind',
@@ -43,7 +43,7 @@ return [
     'reset_password' => 'Nulstil adgangskode',
     'reset_password_send_instructions' => 'Indtast din E-Mail herunder og du vil blive sendt en E-Mail med et link til at nulstille din adgangskode.',
     'reset_password_send_button' => 'Send link til nulstilling',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'Et link til nulstilling af adgangskode sendes til :email, hvis den e-mail-adresse findes i systemet.',
     'reset_password_success' => 'Din adgangskode er blevet nulstillet.',
     'email_reset_subject' => 'Nulstil din :appName adgangskode',
     'email_reset_text' => 'Du modtager denne E-Mail fordi vi har modtaget en anmodning om at nulstille din adgangskode.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Adgangskode indstillet, du har nu adgang til :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.',
 ];
\ No newline at end of file
index 008a9d24e68ae9bfc375c3ba22bcd40b4484ae72..0e426973467deb69384cc925acfe8f1f19d4965c 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Kopier',
     'reply' => 'Besvar',
     'delete' => 'Slet',
+    'delete_confirm' => 'Bekræft sletning',
     'search' => 'Søg',
     'search_clear' => 'Ryd søgning',
     'reset' => 'Nulstil',
     'remove' => 'Fjern',
     'add' => 'Tilføj',
+    'configure' => 'Konfigurer',
     'fullscreen' => 'Fuld skærm',
+    'favourite' => 'Foretrukken',
+    'unfavourite' => 'Fjern som foretrukken',
+    'next' => 'Næste',
+    'previous' => 'Forrige',
 
     // Sort Options
     'sort_options' => 'Sorteringsindstillinger',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Sorter stigende',
     'sort_descending' => 'Sorter faldende',
     'sort_name' => 'Navn',
+    'sort_default' => 'Standard',
     'sort_created_at' => 'Oprettelsesdato',
     'sort_updated_at' => 'Opdateringsdato',
 
@@ -54,6 +61,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,17 +71,25 @@ return [
     'breadcrumb' => 'Brødkrumme',
 
     // Header
+    'header_menu_expand' => 'Udvid header menu',
     'profile_menu' => 'Profilmenu',
     'view_profile' => 'Vis profil',
     'edit_profile' => 'Redigér Profil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Mørk tilstand',
+    'light_mode' => 'Lys tilstand',
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Faneblad: Vis sekundær information',
     'tab_content' => 'Indhold',
+    'tab_content_label' => 'Faneblad: Vis primær indhold',
 
     // Email Content
     'email_action_help' => 'Hvis du har problemer med at trykke på ":actionText" knappen, prøv at kopiere og indsætte linket herunder ind i din webbrowser:',
     'email_rights' => 'Alle rettigheder forbeholdes',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privatlivspolitik',
+    'terms_of_service' => 'Tjenestevilkår',
 ];
index 135dc9d561b592d62b7ee642a0b939d923b7e65e..9ba511fc5124a2cc5a6bf7c05395796c6bc4c77c 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Indlæse mere',
     'image_image_name' => 'Billednavn',
     'image_delete_used' => 'Dette billede er brugt på siderne nedenfor.',
-    'image_delete_confirm' => 'Tryk på slet igen for at bekræft at du ønsker at slette dette billede.',
+    'image_delete_confirm_text' => 'Er du sikker på at du vil slette dette billede?',
     'image_select_image' => 'Vælg billede',
     'image_dropzone' => 'Træk-og-slip billede eller klik her for at uploade',
     'images_deleted' => 'Billede slettet',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Rediger kode',
     'code_language' => 'Kodesprog',
     'code_content' => 'Kodeindhold',
+    'code_session_history' => 'Sessionshistorik',
     'code_save' => 'Gem kode',
 ];
index 3a24677730dbe70acae396fc6c9b6909c507feec..e488e201fa15f15d4b6982c16404a3a95b9c09cf 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Oprettet :timeLength af :user',
     'meta_updated' => 'Opdateret :timeLength',
     'meta_updated_name' => 'Opdateret :timeLength af :user',
+    'meta_owned_name' => 'Ejet af :user',
     'entity_select' => 'Vælg emne',
     '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',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Indeholdt webfil',
     'export_pdf' => 'PDF-fil',
     'export_text' => 'Almindelig tekstfil',
+    'export_md' => 'Markdown Fil',
 
     // Permissions and restrictions
     'permissions' => 'Rettigheder',
     'permissions_intro' => 'Når de er aktiveret, vil disse tilladelser have prioritet over alle indstillede rolletilladelser.',
     'permissions_enable' => 'Aktivér tilpassede tilladelser',
     'permissions_save' => 'Gem tilladelser',
+    'permissions_owner' => 'Ejer',
 
     // Search
     'search_results' => 'Søgeresultater',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Ingen sider matchede søgning',
     'search_for_term' => 'Søgning for :term',
     'search_more' => 'Flere resultater',
-    'search_filters' => 'Søgefiltre',
+    'search_advanced' => 'Avanceret søgning',
+    'search_terms' => 'Søgeord',
     'search_content_type' => 'Indholdstype',
     'search_exact_matches' => 'Nøjagtige matches',
     'search_tags' => 'Tagsøgninger',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Rettigheders sæt',
     'search_created_by_me' => 'Oprettet af mig',
     'search_updated_by_me' => 'Opdateret af mig',
+    'search_owned_by_me' => 'Ejet af mig',
     'search_date_options' => 'Datoindstillinger',
     'search_updated_before' => 'Opdateret før',
     'search_updated_after' => 'Opdateret efter',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Opret nyt kapitel',
     'chapters_delete' => 'Slet kapitel',
     'chapters_delete_named' => 'Slet kapitel :chapterName',
-    'chapters_delete_explain' => 'Dette vil slette kapitlet med navnet \':chapterName\'. Alle sider fjernes og tilføjes direkte til den tilhørende bog.',
+    'chapters_delete_explain' => 'Dette vil slette kapitlet med navnet \':chapterName\'. Alle sider i dette kapitel vil også blive slettet.',
     'chapters_delete_confirm' => 'Er du sikker på du vil slette dette kapitel?',
     'chapters_edit' => 'Rediger kapitel',
     'chapters_edit_named' => 'Rediger kapitel :chapterName',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Sidserevisioner',
     'pages_revisions_named' => 'Siderevisioner for :pageName',
     'pages_revision_named' => 'Siderevision for :pageName',
+    'pages_revision_restored_from' => 'Genoprettet fra #:id; :summary',
     'pages_revisions_created_by' => 'Oprettet af',
     'pages_revisions_date' => 'Revisionsdato',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Upload fil',
     'attachments_link' => 'Vedhæft link',
     'attachments_set_link' => 'Sæt link',
-    'attachments_delete_confirm' => 'Tryk på slet igen for at bekræft at du ønsker at slette denne vedhæftning.',
+    'attachments_delete' => 'Er du sikker på at du vil slette denne vedhæftning?',
     'attachments_dropzone' => 'Slip filer eller klik her for at vedhæfte en fil',
     'attachments_no_files' => 'Ingen filer er blevet overført',
     'attachments_explain_link' => 'Du kan vedhæfte et link, hvis du foretrækker ikke at uploade en fil. Dette kan være et link til en anden side eller et link til en fil i skyen.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Link til filen',
     'attachments_link_url_hint' => 'Adresse (URL) på side eller fil',
     'attach' => 'Vedhæft',
+    'attachments_insert_link' => 'Tilføj vedhæftningslink til side',
     'attachments_edit_file' => 'Rediger fil',
     'attachments_edit_file_name' => 'Filnavn',
     'attachments_edit_drop_upload' => 'Slip filer eller klik her for at uploade og overskrive',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index e5a388c33ac53d4c7c3f97fce46091d0fb156f96..d54cac243b56e31126e82cfe688288a778efde92 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Filuploaden udløb.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Der blev fundet en uoverensstemmelse på siden under opdatering af vedhæftet fil',
     'attachment_not_found' => 'Vedhæftning ikke fundet',
 
     // Pages
@@ -83,7 +82,10 @@ return [
     // Error pages
     '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' => 'If you expected this page to exist, you might not have permission to view it.',
+    '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 85bd12fc319557dcc852fdacc07e882583d66be3..821d7804781ccd7b8c9581b3b9268738ec06b1f9 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'previous' => '&laquo; Previous',
-    'next'     => 'Next &raquo;',
+    'previous' => '&laquo; Forrige',
+    'next'     => 'Næste &raquo;',
 
 ];
index 035ccb2d165bbd8b0fba07e84c01f9019b83479d..343fa2b85246d2a6ac4b45c06a5eec7c6042364e 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Adgangskoder skal være mindst otte tegn og svare til bekræftelsen.',
     'user' => "Vi kan ikke finde en bruger med den e-mail adresse.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Linket til nulstilling af adgangskode er ugyldigt for denne e-mail-adresse.',
     'sent' => 'Vi har sendt dig en e-mail med et link til at nulstille adgangskoden!',
     'reset' => 'Dit kodeord er blevet nulstillet!',
 
index b6f14a42103cbcd3153c781ea873f80773ffc947..cfb4ed908204eb609f391325a795a92316c2a9f7 100644 (file)
@@ -9,13 +9,13 @@ return [
     // Common Messages
     'settings' => 'Indstillinger',
     'settings_save' => 'Gem indstillinger',
-    'settings_save_success' => 'Indstillinger gemt',
+    'settings_save_success' => 'Indstillingerne blev gemt',
 
     // App Settings
     'app_customization' => 'Tilpasning',
-    'app_features_security' => 'Funktioner & sikkerhed',
-    'app_name' => 'Programnavn',
-    'app_name_desc' => 'Dette er navnet vist i headeren og i systemafsendte E-Mails.',
+    'app_features_security' => 'Funktionalitet og sikkerhed',
+    'app_name' => 'Applikationsnavn',
+    'app_name_desc' => 'Dette navn vises i headeren og i alle e-mails sendt fra systemet.',
     'app_name_header' => 'Vis navn i header',
     'app_public_access' => 'Offentlig adgang',
     'app_public_access_desc' => 'Aktivering af denne funktion giver besøgende, der ikke er logget ind, adgang til indhold i din BookStack-instans.',
@@ -24,19 +24,24 @@ return [
     'app_public_viewing' => 'Tillad offentlig visning?',
     'app_secure_images' => 'Højere sikkerhed for billeduploads',
     'app_secure_images_toggle' => 'Aktiver højere sikkerhed for billeduploads',
-    'app_secure_images_desc' => 'Af ydeevneårsager er alle billeder offentlige. Denne funktion tilføjer en tilfældig, vanskelig at gætte streng foran billed-Url\'er. Sørg for, at mappeindekser ikke er aktiveret for at forhindre nem adgang.',
+    'app_secure_images_desc' => 'Af performanceårsager er alle billeder offentlige. Denne funktion tilføjer en tilfældig, vanskelig at gætte streng foran billed-url\'er. Sørg for, at mappeindeksering ikke er aktiveret for at forhindre nem adgang.',
     'app_editor' => 'Sideeditor',
     'app_editor_desc' => 'Vælg hvilken editor der skal bruges af alle brugere til at redigere sider.',
-    'app_custom_html' => 'Tilpasset HTML head-indhold',
-    'app_custom_html_desc' => 'Al indhold tilføjet her, vil blive indsat i bunden af <head> sektionen på alle sider. Dette er brugbart til overskrivning af styling og tilføjelse af analysekode.',
-    'app_custom_html_disabled_notice' => 'Brugerdefineret HTML-head indhold er deaktiveret på denne indstillingsside for at sikre, at ødelæggende ændringer kan rettes.',
-    'app_logo' => 'Programlogo',
-    'app_logo_desc' => 'Dette billede skal være 43px højt. <br> Større billeder vil blive skaleret ned.',
-    'app_primary_color' => 'Primær programfarve',
+    'app_custom_html' => 'Tilpasset HTML head indhold',
+    'app_custom_html_desc' => 'Alt indhold tilføjet her, vil blive indsat i bunden af <head> sektionen på alle sider. Dette er brugbart til overskrivning af styles og tilføjelse af analytics kode.',
+    'app_custom_html_disabled_notice' => 'Brugerdefineret HTML head indhold er deaktiveret på denne indstillingsside for at, at ændringer kan rulles tilbage.',
+    'app_logo' => 'Applikationslogo',
+    'app_logo_desc' => 'Dette billede skal være 43px højt. <br>Store billeder vil blive skaleret ned.',
+    'app_primary_color' => 'Primær applikationsfarve',
     'app_primary_color_desc' => 'Sætter den primære farve for applikationen herunder banneret, knapper og links.',
-    'app_homepage' => 'Programforside',
-    'app_homepage_desc' => 'Vælg en visning, der skal vises på startsiden i stedet for standardvisningen. Sidetilladelser ignoreres for valgte sider.',
+    'app_homepage' => 'Applikationsforside',
+    'app_homepage_desc' => 'Vælg en visning, der skal vises på forsiden i stedet for standardvisningen. Sidetilladelser ignoreres for de valgte sider.',
     'app_homepage_select' => 'Vælg en side',
+    'app_footer_links' => 'Footer links',
+    'app_footer_links_desc' => 'Tilføj links til footeren. Linksene vil blive vist nederst på de fleste sider, inkluderet sider, som ikke kræver login. Brug en label med "trans::<key>" for at bruge systemdefinerede oversættelser. For eksempel: "trans::common.privacy_policy" giver den oversatte tekst "Privacy Policy" og "trans::common.terms_of_service" vil give den oversatte tekst "Terms of Service".',
+    'app_footer_links_label' => 'Link label',
+    'app_footer_links_url' => 'Link URL',
+    'app_footer_links_add' => 'Tilføj footer link',
     'app_disable_comments' => 'Deaktiver kommentarer',
     'app_disable_comments_toggle' => 'Deaktiver kommentar',
     'app_disable_comments_desc' => 'Deaktiverer kommentarer på tværs af alle sider i applikationen. <br> Eksisterende kommentarer vises ikke.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignorer billeder i revisioner',
+    '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?',
     'maint_image_cleanup_success' => ':count: potentielt ubrugte billeder fundet og slettet!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Test E-Mail',
     'maint_send_test_email_mail_greeting' => 'E-Mail levering ser ud til at virke!',
     'maint_send_test_email_mail_text' => 'Tillykke! Da du har modtaget denne mailnotifikation, ser det ud som om, at dine mailindstillinger er opsat korrekt.',
+    'maint_recycle_bin_desc' => 'Slettede hylder, bøger, kapitler og sider overføres til papirkurven, så de kan gendannes eller slettes permanent. Ældre elementer i papirkurven fjernes automatisk efter et stykke tid afhængigt af systemets konfiguration.',
+    'maint_recycle_bin_open' => 'Åbn papirkurven',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Gendan',
+    'recycle_bin_contents_empty' => 'Papirkurven er tom',
+    'recycle_bin_empty' => 'Tøm papirkurv',
+    'recycle_bin_empty_confirm' => 'Dette vil permanent slette alle elementer i papirkurven, inkluderet hvert elements indhold. Er du sikker på, at du vil tømme papirkurven?',
+    'recycle_bin_destroy_confirm' => 'Denne handling sletter dette element permanent, sammen med elementerne anført nedenfor, fra systemet. Du vil ikke være i stand til at gendanne dette indhold. Er du sikker på, at du vil slette dette element permanent?',
+    'recycle_bin_destroy_list' => 'Elementer der skal slettes',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Revisionslog',
+    'audit_desc' => 'Denne revisionslog viser en liste over aktiviteter sporet i systemet. Denne liste er ufiltreret i modsætning til lignende aktivitetslister i systemet, hvor tilladelsesfiltre anvendes.',
+    'audit_event_filter' => 'Event filter',
+    'audit_event_filter_no_filter' => 'Intet filter',
+    'audit_deleted_item' => 'Element slettet',
+    'audit_deleted_item_name' => 'Navn: :name',
+    'audit_table_user' => 'Bruger',
+    'audit_table_event' => 'Hændelse',
+    'audit_table_related' => 'Relateret element eller detalje',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Aktivitetsdato',
+    'audit_date_from' => 'Datointerval fra',
+    'audit_date_to' => 'Datointerval til',
 
     // Role Settings
     'roles' => 'Roller',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Administratorer får automatisk adgang til alt indhold, men disse indstillinger kan vise eller skjule UI-indstillinger.',
     'role_all' => 'Alle',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Brugerprofil',
     'users_add_new' => 'Tilføj ny bruger',
     'users_search' => 'Søg efter brugere',
+    'users_latest_activity' => 'Seneste aktivitet',
     'users_details' => 'Brugeroplysninger',
     'users_details_desc' => 'Angiv et visningsnavn og en E-Mail-adresse for denne bruger. E-Mail-adressen bruges til at logge ind på applikationen.',
     'users_details_desc_no_email' => 'Sætter et visningsnavn for denne bruger, så andre kan genkende dem.',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => 'Slet bruger :userName',
     'users_delete_warning' => 'Dette vil helt slette denne bruger med navnet \':userName\' fra systemet.',
     'users_delete_confirm' => 'Er du sikker på, at du vil slette denne bruger?',
-    'users_delete_success' => 'Brugere blev fjernet',
+    'users_migrate_ownership' => 'Overfør ejerskab',
+    'users_migrate_ownership_desc' => 'Vælg en bruger her, hvis du vil have en anden bruger til at blive ejer af alle elementer, der i øjeblikket ejes af denne bruger.',
+    'users_none_selected' => 'Ingen bruger valgt',
+    'users_delete_success' => 'Brugeren blev fjernet',
     'users_edit' => 'Rediger bruger',
     'users_edit_profile' => 'Rediger profil',
     'users_edit_success' => 'Bruger suscesfuldt opdateret',
@@ -157,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',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Catalansk',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -192,13 +249,19 @@ return [
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
         'fr' => 'Français',
-        'he' => 'עברית',
+        '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)',
         'pl' => 'Polski',
+        'pt' => 'Português',
         'pt_BR' => 'Português do Brasil',
         'ru' => 'Русский',
         'sk' => 'Slovensky',
index 79ed1bdf455aa67c10e7a2b2704e4ec7cbc20347..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute skal mindst være :min tegn.',
         'array'   => ':attribute skal have mindst :min elementer.',
     ],
-    'no_double_extension'  => ':attribute må kun indeholde én filtype.',
     'not_in'               => 'Den valgte :attribute er ikke gyldig.',
     'not_regex'            => ':attribute-formatet er ugyldigt.',
     'numeric'              => ':attribute skal være et tal.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute skal udfyldes når :values ikke er udfyldt.',
     'required_without_all' => ':attribute skal udfyldes når ingen af :values er udfyldt.',
     'same'                 => ':attribute og :other skal være ens.',
+    'safe_url'             => 'Det angivne link kan være usikkert.',
     'size'                 => [
         'numeric' => ':attribute skal være :size.',
         'file'    => ':attribute skal være :size kilobytes.',
@@ -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 fadf5d6380caee444c365db2c8117f8434eabdb9..87dd3ee8ba534157be8a9e8d2c969dbb1d709112 100644 (file)
@@ -6,43 +6,52 @@
 return [
 
     // Pages
-    'page_create'                 => 'erstellte Seite',
-    'page_create_notification'    => 'Die Seite wurde erfolgreich erstellt.',
-    'page_update'                 => 'aktualisierte Seite',
-    'page_update_notification'    => 'Die Seite wurde erfolgreich aktualisiert.',
-    'page_delete'                 => 'gelöschte Seite',
-    'page_delete_notification'    => 'Die Seite wurde erfolgreich gelöscht.',
-    'page_restore'                => 'wiederhergestellte Seite',
-    'page_restore_notification'   => 'Die Seite wurde erfolgreich wiederhergestellt.',
-    'page_move'                   => 'Seite verschoben',
+    'page_create'                 => 'hat die Seite erstellt',
+    'page_create_notification'    => 'Die Seite wurde erfolgreich erstellt',
+    'page_update'                 => 'hat die Seite aktualisiert',
+    'page_update_notification'    => 'Die Seite wurde erfolgreich aktualisiert',
+    'page_delete'                 => 'hat die Seite gelöscht',
+    'page_delete_notification'    => 'Die Seite wurde erfolgreich gelöscht',
+    'page_restore'                => 'hat die Seite wiederhergestellt',
+    'page_restore_notification'   => 'Die Seite wurde erfolgreich wiederhergestellt',
+    'page_move'                   => 'hat die Seite verschoben',
 
     // Chapters
-    'chapter_create'              => 'erstellte Kapitel',
-    'chapter_create_notification' => 'Das Kapitel wurde erfolgreich erstellt.',
-    'chapter_update'              => 'aktualisierte Kapitel',
-    'chapter_update_notification' => 'Das Kapitel wurde erfolgreich aktualisiert.',
-    'chapter_delete'              => 'löschte Kapitel',
-    'chapter_delete_notification' => 'Das Kapitel wurde erfolgreich gelöscht.',
-    'chapter_move'                => 'verschob Kapitel',
+    'chapter_create'              => 'hat das Kapitel erstellt',
+    'chapter_create_notification' => 'Das Kapitel wurde erfolgreich erstellt',
+    'chapter_update'              => 'hat das Kapitel geändert',
+    'chapter_update_notification' => 'Das Kapitel wurde erfolgreich aktualisiert',
+    'chapter_delete'              => 'hat das Kapitel gelöscht',
+    'chapter_delete_notification' => 'Das Kapitel wurde erfolgreich gelöscht',
+    'chapter_move'                => 'hat das Kapitel verschoben',
 
     // Books
-    'book_create'                 => 'erstellte Buch',
-    'book_create_notification'    => 'Das Buch wurde erfolgreich erstellt.',
-    'book_update'                 => 'aktualisierte Buch',
-    'book_update_notification'    => 'Das Buch wurde erfolgreich aktualisiert.',
-    'book_delete'                 => 'löschte Buch',
-    'book_delete_notification'    => 'Das Buch wurde erfolgreich gelöscht.',
-    'book_sort'                   => 'sortierte Buch',
-    'book_sort_notification'      => 'Das Buch wurde erfolgreich umsortiert.',
+    'book_create'                 => 'hat das Buch erstellt',
+    'book_create_notification'    => 'Das Buch wurde erfolgreich erstellt',
+    'book_update'                 => 'hat das Buch aktualisiert',
+    'book_update_notification'    => 'Das Buch wurde erfolgreich aktualisiert',
+    'book_delete'                 => 'hat das Buch gelöscht',
+    'book_delete_notification'    => 'Das Buch wurde erfolgreich gelöscht',
+    'book_sort'                   => 'hat die Buch-Sortierung geändert',
+    'book_sort_notification'      => 'Das Buch wurde erfolgreich umsortiert',
 
     // Bookshelves
-    'bookshelf_create'            => 'erstellt Bücherregal',
+    'bookshelf_create'            => 'hat das Bücherregal erstellt',
     'bookshelf_create_notification'    => 'Das Bücherregal wurde erfolgreich erstellt',
-    'bookshelf_update'                 => 'aktualisiert Bücherregal',
-    'bookshelf_update_notification'    => 'Das Bücherregal wurde erfolgreich aktualisiert',
-    'bookshelf_delete'                 => 'löscht Bücherregal',
+    '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',
+
     // Other
-    'commented_on'                => 'kommentiert',
+    'commented_on'                => 'hat einen Kommentar hinzugefügt',
+    'permissions_update'          => 'hat die Berechtigungen aktualisiert',
 ];
index 1f5a49cbddc240b36b70ff02fec035e6355abdf3..efe82680da55808c0e42fac0c28378ac5c172d55 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,7 +20,7 @@ return [
     'username' => 'Benutzername',
     'email' => 'E-Mail',
     'password' => 'Passwort',
-    'password_confirm' => 'Passwort best&auml;tigen',
+    'password_confirm' => 'Passwort bestätigen',
     'password_hint' => 'Mindestlänge: 7 Zeichen',
     'forgot_password' => 'Passwort vergessen?',
     'remember_me' => 'Angemeldet bleiben',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Passwort gesetzt, Sie haben nun Zugriff auf :appName!',
+
+    // 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.',
 ];
\ No newline at end of file
index 5d0ad554a18f0939ae432c92f3dab8e825586182..bd75e1737dc499330b4b5e7c6117ec5f4985734e 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Kopieren',
     'reply' => 'Antworten',
     'delete' => 'Löschen',
+    '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',
 
     // Sort Options
     'sort_options' => 'Sortieroptionen',
@@ -46,14 +52,16 @@ return [
     'sort_ascending' => 'Aufsteigend sortieren',
     'sort_descending' => 'Absteigend sortieren',
     'sort_name' => 'Name',
+    'sort_default' => 'Standard',
     'sort_created_at' => 'Erstellungsdatum',
     '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,7 @@ return [
     'breadcrumb' => 'Brotkrumen',
 
     // Header
+    'header_menu_expand' => 'Header-Menü erweitern',
     'profile_menu' => 'Profilmenü',
     'view_profile' => 'Profil ansehen',
     'edit_profile' => 'Profil bearbeiten',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: Sekundäre Informationen anzeigen',
     'tab_content' => 'Inhalt',
+    'tab_content_label' => 'Tab: Hauptinhalt anzeigen',
 
     // Email Content
     'email_action_help' => 'Sollte es beim Anklicken der Schaltfläche ":action_text" Probleme geben, öffnen Sie folgende URL in Ihrem Browser:',
     'email_rights' => 'Alle Rechte vorbehalten',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Datenschutzbestimmungen',
+    'terms_of_service' => 'Allgemeine Geschäftsbedingungen',
 ];
index 4e56722a8cda3dac9dac2d0cb74f27281907d96b..bda1ce3768e0e0d119e367af25689ab0aa752c2a 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Mehr',
     'image_image_name' => 'Bildname',
     'image_delete_used' => 'Dieses Bild wird auf den folgenden Seiten benutzt. ',
-    'image_delete_confirm' => 'Bitte klicken Sie erneut auf löschen, wenn Sie dieses Bild wirklich entfernen möchten.',
+    'image_delete_confirm_text' => 'Möchten Sie dieses Bild wirklich löschen?',
     'image_select_image' => 'Bild auswählen',
     'image_dropzone' => 'Ziehen Sie Bilder hierher oder klicken Sie, um ein Bild auszuwählen',
     'images_deleted' => 'Bilder gelöscht',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Code editieren',
     'code_language' => 'Code Sprache',
     'code_content' => 'Code Inhalt',
+    'code_session_history' => 'Sitzungsverlauf',
     'code_save' => 'Code speichern',
 ];
index e666c664cfbcf3aa9c02105070a6cc1d9eef08e0..fdb26375878bb00be2b3ac04ad93172c48ce2c68 100644 (file)
@@ -22,23 +22,28 @@ return [
     'meta_created_name' => 'Erstellt: :timeLength von :user',
     'meta_updated' => 'Zuletzt aktualisiert: :timeLength',
     'meta_updated_name' => 'Zuletzt aktualisiert: :timeLength von :user',
+    'meta_owned_name' => 'Im Besitz von :user',
     'entity_select' => 'Eintrag auswählen',
     'images' => 'Bilder',
     'my_recent_drafts' => 'Meine kürzlichen Entwürfe',
     'my_recently_viewed' => 'Kürzlich von mir angesehen',
-    '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.',
+    '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',
     'export' => 'Exportieren',
     'export_html' => 'HTML-Datei',
     'export_pdf' => 'PDF-Datei',
     'export_text' => 'Textdatei',
+    'export_md' => 'Markdown-Datei',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
     'permissions_intro' => 'Wenn individuelle Berechtigungen aktiviert werden, überschreiben diese Einstellungen durch Rollen zugewiesene Berechtigungen.',
     'permissions_enable' => 'Individuelle Berechtigungen aktivieren',
     'permissions_save' => 'Berechtigungen speichern',
+    'permissions_owner' => 'Besitzer',
 
     // Search
     'search_results' => 'Suchergebnisse',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Keine Seiten gefunden',
     'search_for_term' => 'Nach :term suchen',
     'search_more' => 'Mehr Ergebnisse',
-    'search_filters' => 'Filter',
+    'search_advanced' => 'Erweiterte Suche',
+    'search_terms' => 'Suchbegriffe',
     'search_content_type' => 'Inhaltstyp',
     'search_exact_matches' => 'Exakte Treffer',
     'search_tags' => 'Nach Schlagwort suchen',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Berechtigungen gesetzt',
     'search_created_by_me' => 'Von mir erstellt',
     'search_updated_by_me' => 'Von mir aktualisiert',
+    'search_owned_by_me' => 'Besitzt von mir',
     'search_date_options' => 'Datums Optionen',
     'search_updated_before' => 'Aktualisiert vor',
     'search_updated_after' => 'Aktualisiert nach',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Neues Kapitel anlegen',
     'chapters_delete' => 'Kapitel entfernen',
     'chapters_delete_named' => 'Kapitel ":chapterName" entfernen',
-    'chapters_delete_explain' => 'Das Kapitel ":chapterName" wird gelöscht und alle zugehörigen Seiten dem übergeordneten Buch zugeordnet.',
+    'chapters_delete_explain' => 'Dies löscht das Kapitel mit dem Namen \':chapterName\'. Alle Seiten, die innerhalb dieses Kapitels existieren, werden ebenfalls gelöscht.',
     'chapters_delete_confirm' => 'Sind Sie sicher, dass Sie dieses Kapitel löschen möchten?',
     'chapters_edit' => 'Kapitel bearbeiten',
     'chapters_edit_named' => 'Kapitel ":chapterName" bearbeiten',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Seitenversionen',
     'pages_revisions_named' => 'Seitenversionen von ":pageName"',
     'pages_revision_named' => 'Seitenversion von ":pageName"',
+    'pages_revision_restored_from' => 'Wiederhergestellt von #:id; :summary',
     'pages_revisions_created_by' => 'Erstellt von',
     'pages_revisions_date' => 'Versionsdatum',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Datei hochladen',
     'attachments_link' => 'Link hinzufügen',
     'attachments_set_link' => 'Link setzen',
-    'attachments_delete_confirm' => 'Klicken Sie erneut auf löschen, um diesen Anhang zu entfernen.',
+    'attachments_delete' => 'Sind Sie sicher, dass Sie diesen Anhang löschen möchten?',
     'attachments_dropzone' => 'Ziehen Sie Dateien hierher oder klicken Sie, um eine Datei auszuwählen',
     'attachments_no_files' => 'Es wurden bisher keine Dateien hochgeladen.',
     'attachments_explain_link' => 'Wenn Sie keine Datei hochladen möchten, können Sie stattdessen einen Link hinzufügen. Dieser Link kann auf eine andere Seite oder eine Datei im Internet weisen.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Link zu einer Datei',
     'attachments_link_url_hint' => 'URL einer Seite oder Datei',
     'attach' => 'Hinzufügen',
+    'attachments_insert_link' => 'Link zum Anhang auf Seite einfügen',
     'attachments_edit_file' => 'Datei bearbeiten',
     'attachments_edit_file_name' => 'Dateiname',
     'attachments_edit_drop_upload' => 'Ziehen Sie Dateien hierher, um diese hochzuladen und zu überschreiben',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index 205a8a6325576baf304650d634d839ba495fce6d..65174eada10d69421c1be5e7becf3f6f02ebab62 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.',
@@ -43,15 +43,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_page_mismatch' => 'Die Seite stimmte nach dem Hochladen des Anhangs nicht überein.',
     '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',
@@ -59,45 +58,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 d8ffedbf2ee00c9ea40bda55215471f8f128c79f..d24319c18ba1168c887ecfa1243f108b3ad0a27e 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?',
@@ -39,6 +39,11 @@ Wenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt
     'app_homepage' => 'Startseite der Anwendung',
     'app_homepage_desc' => 'Wählen Sie eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.',
     'app_homepage_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_label' => 'Link-Label',
+    'app_footer_links_url' => 'Link-URL',
+    'app_footer_links_add' => 'Fußzeilen-Link hinzufügen',
     'app_disable_comments' => 'Kommentare deaktivieren',
     'app_disable_comments_toggle' => 'Kommentare deaktivieren',
     'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.',
@@ -54,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',
@@ -71,7 +76,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     '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_ignore_revisions' => 'Bilder in Versionen ignorieren',
+    '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?',
     'maint_image_cleanup_success' => ':count eventuell unbenutze Bilder wurden gefunden und gelöscht.',
@@ -83,6 +88,44 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'maint_send_test_email_mail_subject' => 'Test E-Mail',
     'maint_send_test_email_mail_greeting' => 'E-Mail-Versand scheint zu funktionieren!',
     'maint_send_test_email_mail_text' => 'Glückwunsch! Da Sie diese E-Mail Benachrichtigung erhalten haben, scheinen Ihre E-Mail-Einstellungen korrekt konfiguriert zu sein.',
+    'maint_recycle_bin_desc' => 'Gelöschte Regale, Bücher, Kapitel & Seiten werden in den Papierkorb verschoben, so dass sie wiederhergestellt oder dauerhaft gelöscht werden können. Ältere Gegenstände im Papierkorb können, in Abhängigkeit von der Systemkonfiguration, nach einer Weile automatisch entfernt werden.',
+    'maint_recycle_bin_open' => 'Papierkorb öffnen',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Wiederherstellen',
+    'recycle_bin_contents_empty' => 'Der Papierkorb ist derzeit leer',
+    'recycle_bin_empty' => 'Papierkorb leeren',
+    'recycle_bin_empty_confirm' => 'Dies wird alle Gegenstände im Papierkorb dauerhaft entfernen, einschließlich der Inhalte, die darin enthalten sind. Sind Sie sicher, dass Sie den Papierkorb leeren möchten?',
+    'recycle_bin_destroy_confirm' => 'Diese Aktion wird dieses Element zusammen mit allen unten aufgeführten Unterelementen dauerhaft aus dem System löschen und Sie werden nicht in der Lage sein, diesen Inhalt wiederherzustellen. Sind Sie sicher, dass Sie dieses Element endgültig löschen möchten?',
+    'recycle_bin_destroy_list' => 'Zu löschende Elemente',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Audit-Protokoll',
+    'audit_desc' => 'Dieses Audit-Protokoll zeigt eine Liste der Aktivitäten an, welche vom System protokolliert werden. Im Gegensatz zu den anderen Aktivitätslisten im System, bei denen Berechtigungen angewendet werden, ist diese Liste ungefiltert.',
+    'audit_event_filter' => 'Ereignisfilter',
+    'audit_event_filter_no_filter' => 'Kein Filter',
+    'audit_deleted_item' => 'Gelöschtes Objekt',
+    'audit_deleted_item_name' => 'Name: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Rollen',
@@ -99,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',
@@ -108,7 +152,9 @@ 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.',
     'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, aber diese Optionen können Oberflächenoptionen ein- oder ausblenden.',
     'role_all' => 'Alle',
@@ -124,6 +170,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'user_profile' => 'Benutzerprofil',
     'users_add_new' => 'Benutzer hinzufügen',
     'users_search' => 'Benutzer suchen',
+    'users_latest_activity' => 'Neueste Aktivitäten',
     'users_details' => 'Benutzerdetails',
     'users_details_desc' => 'Legen Sie für diesen Benutzer einen Anzeigenamen und eine E-Mail-Adresse fest. Die E-Mail-Adresse wird bei der Anmeldung verwendet.',
     'users_details_desc_no_email' => 'Legen Sie für diesen Benutzer einen Anzeigenamen fest, damit andere ihn erkennen können.',
@@ -141,7 +188,10 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_delete_named' => 'Benutzer ":userName" löschen',
     'users_delete_warning' => 'Der Benutzer ":userName" wird aus dem System gelöscht.',
     'users_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?',
-    'users_delete_success' => 'Benutzer erfolgreich gelöscht.',
+    'users_migrate_ownership' => 'Besitz migrieren',
+    'users_migrate_ownership_desc' => 'Wählen Sie hier einen Benutzer, wenn Sie möchten, dass ein anderer Benutzer der Besitzer aller Einträge wird, die diesem Benutzer derzeit gehören.',
+    'users_none_selected' => 'Kein Benutzer ausgewählt',
+    'users_delete_success' => 'Benutzer erfolgreich entfernt',
     'users_edit' => 'Benutzer bearbeiten',
     'users_edit_profile' => 'Profil bearbeiten',
     'users_edit_success' => 'Benutzer erfolgreich aktualisisert',
@@ -160,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',
@@ -188,6 +242,9 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bulgarisch',
+        'bs' => 'Bosanski',
+        'ca' => 'Katalanisch',
         'cs' => 'Česky',
         'da' => 'Dänisch',
         'de' => 'Deutsch (Sie)',
@@ -196,12 +253,18 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'es_AR' => 'Español Argentina',
         '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)',
         'pl' => 'Polski',
+        'pt' => 'Português',
         'pt_BR' => 'Português do Brasil',
         'ru' => 'Русский',
         'sk' => 'Slovensky',
index b6105d192858da66ce689644cfc6ef6cee60a34e..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute muss mindestens :min Zeichen lang sein.',
         'array'   => ':attribute muss mindesten :min Elemente enthalten.',
     ],
-    'no_double_extension'  => ':attribute darf nur eine gültige Dateiendung',
     'not_in'               => ':attribute ist ungültig.',
     'not_regex'            => ':attribute ist kein valides Format.',
     'numeric'              => ':attribute muss eine Zahl sein.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute ist erforderlich, wenn :values nicht vorhanden ist.',
     'required_without_all' => ':attribute ist erforderlich, wenn :values nicht vorhanden sind.',
     'same'                 => ':attribute und :other müssen übereinstimmen.',
+    'safe_url'             => 'Der angegebene Link ist möglicherweise nicht sicher.',
     'size'                 => [
         'numeric' => ':attribute muss :size sein.',
         'file'    => ':attribute muss :size Kilobytes groß sein.',
@@ -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 170a1910825ac8637a702183c274b87f55dda810..fec33bec224418fce45436e2b30ddee455726485 100644 (file)
@@ -43,6 +43,15 @@ return [
     '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-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
     // Other
     'commented_on'                => 'kommentiert',
+    'permissions_update'          => 'aktualisierte Berechtigungen',
 ];
index 918598533969b4d69d2c38f9a03b42d367f79069..d09008e5a080aebb910fc7182cb487cb31ad2632 100644 (file)
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Das Passwort wurde gesetzt, du hast nun Zugriff auf :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.',
 ];
\ No newline at end of file
index c54b23afc1d0cdac35e5b866d1acb681cc4053c2..898df928e5421c881957963e0c6236b85f57945d 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Kopieren',
     'reply' => 'Antworten',
     'delete' => 'Löschen',
+    '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',
 
     // Sort Options
     'sort_options' => 'Sortieroptionen',
@@ -46,14 +52,16 @@ return [
     'sort_ascending' => 'Aufsteigend sortieren',
     'sort_descending' => 'Absteigend sortieren',
     'sort_name' => 'Name',
+    'sort_default' => 'Standard',
     'sort_created_at' => 'Erstellungsdatum',
     '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,7 @@ return [
     'breadcrumb' => 'Brotkrumen',
 
     // Header
+    'header_menu_expand' => 'Header-Menü erweitern',
     'profile_menu' => 'Profilmenü',
     'view_profile' => 'Profil ansehen',
     'edit_profile' => 'Profil bearbeiten',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: Sekundäre Informationen anzeigen',
     'tab_content' => 'Inhalt',
+    'tab_content_label' => 'Tab: Hauptinhalt anzeigen',
 
     // Email Content
     'email_action_help' => 'Sollte es beim Anklicken der Schaltfläche ":action_text" Probleme geben, öffne die folgende URL in Deinem Browser:',
     'email_rights' => 'Alle Rechte vorbehalten',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Datenschutzerklärung',
+    'terms_of_service' => 'Allgemeine Geschäftsbedingungen',
 ];
index 4d98235a4370b6a243b7ec90ee3cd7553232bc2c..56060ea236e1e30f6eeacfc44622cdc24f0e29ec 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Mehr',
     'image_image_name' => 'Bildname',
     'image_delete_used' => 'Dieses Bild wird auf den folgenden Seiten benutzt. ',
-    'image_delete_confirm' => 'Bitte klicke erneut auf löschen, wenn Du dieses Bild wirklich entfernen möchtest.',
+    'image_delete_confirm_text' => 'Bist Du sicher, dass Du diese Seite löschen möchtest?',
     'image_select_image' => 'Bild auswählen',
     'image_dropzone' => 'Ziehe Bilder hierher oder klicke hier, um ein Bild auszuwählen',
     'images_deleted' => 'Bilder gelöscht',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Code editieren',
     'code_language' => 'Code Sprache',
     'code_content' => 'Code Inhalt',
+    'code_session_history' => 'Sitzungsverlauf',
     'code_save' => 'Code speichern',
 ];
index 3888035c315af5b7730c4e2e740df4ccfc69ace9..3f8bccaed1c823cef9412cb56b54ecdd87921f4f 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Erstellt: :timeLength von :user',
     'meta_updated' => 'Zuletzt aktualisiert: :timeLength',
     'meta_updated_name' => 'Zuletzt aktualisiert: :timeLength von :user',
+    'meta_owned_name' => 'Im Besitz von :user',
     'entity_select' => 'Eintrag auswählen',
     '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.',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'HTML-Datei',
     'export_pdf' => 'PDF-Datei',
     'export_text' => 'Textdatei',
+    'export_md' => 'Markdown-Dateir',
 
     // Permissions and restrictions
     'permissions' => 'Berechtigungen',
     'permissions_intro' => 'Wenn individuelle Berechtigungen aktiviert werden, überschreiben diese Einstellungen durch Rollen zugewiesene Berechtigungen.',
     'permissions_enable' => 'Individuelle Berechtigungen aktivieren',
     'permissions_save' => 'Berechtigungen speichern',
+    'permissions_owner' => 'Besitzer',
 
     // Search
     'search_results' => 'Suchergebnisse',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Keine Seiten gefunden',
     'search_for_term' => 'Nach :term suchen',
     'search_more' => 'Mehr Ergebnisse',
-    'search_filters' => 'Filter',
+    'search_advanced' => 'Erweiterte Suche',
+    'search_terms' => 'Suchbegriffe',
     'search_content_type' => 'Inhaltstyp',
     'search_exact_matches' => 'Exakte Treffer',
     'search_tags' => 'Nach Schlagwort suchen',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Berechtigungen gesetzt',
     'search_created_by_me' => 'Von mir erstellt',
     'search_updated_by_me' => 'Von mir aktualisiert',
+    'search_owned_by_me' => 'Besitzt von mir',
     'search_date_options' => 'Datums Optionen',
     'search_updated_before' => 'Aktualisiert vor',
     'search_updated_after' => 'Aktualisiert nach',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Neues Kapitel anlegen',
     'chapters_delete' => 'Kapitel entfernen',
     'chapters_delete_named' => 'Kapitel ":chapterName" entfernen',
-    'chapters_delete_explain' => 'Das Kapitel ":chapterName" wird gelöscht und alle zugehörigen Seiten dem übergeordneten Buch zugeordnet.',
+    'chapters_delete_explain' => 'Hiermit löschen Sie das Kapitel mit dem Namen \':chapterName\'. Alle Seiten, die innerhalb dieses Kapitels existieren, werden ebenfalls gelöscht.',
     'chapters_delete_confirm' => 'Bist Du sicher, dass Du dieses Kapitel löschen möchtest?',
     'chapters_edit' => 'Kapitel bearbeiten',
     'chapters_edit_named' => 'Kapitel ":chapterName" bearbeiten',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Seitenversionen',
     'pages_revisions_named' => 'Seitenversionen von ":pageName"',
     'pages_revision_named' => 'Seitenversion von ":pageName"',
+    'pages_revision_restored_from' => 'Wiederhergestellt von #:id; :summary',
     'pages_revisions_created_by' => 'Erstellt von',
     'pages_revisions_date' => 'Versionsdatum',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Datei hochladen',
     'attachments_link' => 'Link hinzufügen',
     'attachments_set_link' => 'Link setzen',
-    'attachments_delete_confirm' => 'Klicke erneut auf löschen, um diesen Anhang zu entfernen.',
+    'attachments_delete' => 'Bist Du sicher, dass Du diesen Anhang löschen möchtest?',
     'attachments_dropzone' => 'Ziehe Dateien hierher oder klicke hier, um eine Datei auszuwählen',
     'attachments_no_files' => 'Es wurden bisher keine Dateien hochgeladen.',
     'attachments_explain_link' => 'Wenn Du keine Datei hochladen möchtest, kannst Du stattdessen einen Link hinzufügen. Dieser Link kann auf eine andere Seite oder eine Datei im Internet verweisen.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Link zu einer Datei',
     'attachments_link_url_hint' => 'URL einer Seite oder Datei',
     'attach' => 'Hinzufügen',
+    'attachments_insert_link' => 'Anhangslink zur Seite hinzufügen',
     'attachments_edit_file' => 'Datei bearbeiten',
     'attachments_edit_file_name' => 'Dateiname',
     'attachments_edit_drop_upload' => 'Ziehe Dateien hierher, um diese hochzuladen und zu überschreiben',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index 3707dbf13768e2647f41ebb5abcffa49c057fa48..181051459f5fd62f6d22f3f6c2190de93016a590 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Der Upload der Datei ist abgelaufen.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Die Seite stimmte nach dem Hochladen des Anhangs nicht überein.',
     'attachment_not_found' => 'Anhang konnte nicht gefunden werden.',
 
     // Pages
@@ -84,6 +83,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 Bild, die Sie angefordert haben, 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 3da092cb8c7e234a0f5a852afb272e183a5999d9..0dd28c019f13452473408060a81110c5806da8a5 100644 (file)
@@ -8,8 +8,8 @@ return [
 
     'password' => 'Passwörter müssen aus mindestens sechs 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.',
-    'reset' => 'Ihr Passwort wurde zurückgesetzt!',
+    'token' => 'Der Token zum Zurücksetzen des Passworts für diese E-Mail-Adresse ist ungültig.',
+    'sent' => 'Wir haben dir einen Link zum Zurücksetzen des Passwortes per E-Mail geschickt!',
+    'reset' => 'Dein Passwort wurde zurückgesetzt!',
 
 ];
index a39e00e04ccb40c966255818bd0ddbf6b63af74e..53d8f8359a26ef802eebc0a64f1e1ac3512a3699 100644 (file)
@@ -39,6 +39,11 @@ Wenn Du nichts eingibst, wird die Anwendung auf die Standardfarbe zurückgesetzt
     'app_homepage' => 'Startseite der Anwendung',
     'app_homepage_desc' => 'Wähle eine Seite als Startseite aus, die statt der Standardansicht angezeigt werden soll. Seitenberechtigungen werden für die ausgewählten Seiten ignoriert.',
     'app_homepage_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_label' => 'Link-Label',
+    'app_footer_links_url' => 'Link-URL',
+    'app_footer_links_add' => 'Fußzeilenlink hinzufügen',
     'app_disable_comments' => 'Kommentare deaktivieren',
     'app_disable_comments_toggle' => 'Kommentare deaktivieren',
     'app_disable_comments_desc' => 'Deaktiviert Kommentare über alle Seiten in der Anwendung. Vorhandene Kommentare werden nicht angezeigt.',
@@ -71,7 +76,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     '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_ignore_revisions' => 'Bilder in Versionen ignorieren',
+    '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?',
     'maint_image_cleanup_success' => ':count eventuell unbenutze Bilder wurden gefunden und gelöscht.',
@@ -83,6 +88,44 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'maint_send_test_email_mail_subject' => 'Test E-Mail',
     'maint_send_test_email_mail_greeting' => 'E-Mail-Versand scheint zu funktionieren!',
     'maint_send_test_email_mail_text' => 'Glückwunsch! Da du diese E-Mail Benachrichtigung erhalten hast, scheinen deine E-Mail-Einstellungen korrekt konfiguriert zu sein.',
+    'maint_recycle_bin_desc' => 'Gelöschte Regale, Bücher, Kapitel & Seiten werden in den Papierkorb verschoben, so dass sie wiederhergestellt oder dauerhaft gelöscht werden können. Ältere Einträge im Papierkorb können, in Abhängigkeit von der Systemkonfiguration, nach einer Weile automatisch entfernt werden.',
+    'maint_recycle_bin_open' => 'Papierkorb öffnen',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Wiederherstellen',
+    'recycle_bin_contents_empty' => 'Der Papierkorb ist derzeit leer',
+    'recycle_bin_empty' => 'Papierkorb leeren',
+    'recycle_bin_empty_confirm' => 'Dies wird alle Einträge im Papierkorb dauerhaft entfernen, einschließlich der Inhalte, die darin enthalten sind. Sind Sie sicher, dass Sie den Papierkorb leeren möchten?',
+    'recycle_bin_destroy_confirm' => 'Diese Aktion wird diesen Eintrag zusammen mit allen unten aufgeführten Untereinträgen dauerhaft aus dem System löschen und Sie werden nicht in der Lage sein, diesen Inhalt wiederherzustellen. Sind Sie sicher, dass Sie diesen Eintrag endgültig löschen möchten?',
+    'recycle_bin_destroy_list' => 'Zu löschende Einträge',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Änderungsprotokoll',
+    'audit_desc' => 'Dieses Audit-Protokoll zeigt eine Liste der Aktivitäten an, welche vom System protokolliert werden. Im Gegensatz zu den anderen Aktivitätslisten im System, bei denen Berechtigungen angewendet werden, ist diese Liste ungefiltert.',
+    'audit_event_filter' => 'Ereignisfilter',
+    'audit_event_filter_no_filter' => 'Kein Filter',
+    'audit_deleted_item' => 'Gelöschtes Element',
+    'audit_deleted_item_name' => 'Name: :name',
+    'audit_table_user' => 'Benutzer',
+    'audit_table_event' => 'Ereignis',
+    'audit_table_related' => 'Verknüpfter Eintrag oder Detail',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Aktivitätsdatum',
+    'audit_date_from' => 'Zeitraum von',
+    'audit_date_to' => 'Zeitraum bis',
 
     // Role Settings
     'roles' => 'Rollen',
@@ -99,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',
@@ -108,7 +152,9 @@ 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.',
     'role_asset_admins' => 'Administratoren erhalten automatisch Zugriff auf alle Inhalte, aber diese Optionen können Oberflächenoptionen ein- oder ausblenden.',
     'role_all' => 'Alle',
@@ -124,6 +170,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'user_profile' => 'Benutzerprofil',
     'users_add_new' => 'Benutzer hinzufügen',
     'users_search' => 'Benutzer suchen',
+    'users_latest_activity' => 'Neueste Aktivitäten',
     'users_details' => 'Benutzerdetails',
     'users_details_desc' => 'Legen Sie für diesen Benutzer einen Anzeigenamen und eine E-Mail-Adresse fest. Die E-Mail-Adresse wird bei der Anmeldung verwendet.',
     'users_details_desc_no_email' => 'Legen Sie für diesen Benutzer einen Anzeigenamen fest, damit andere ihn erkennen können.',
@@ -141,7 +188,10 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'users_delete_named' => 'Benutzer ":userName" löschen',
     'users_delete_warning' => 'Der Benutzer ":userName" wird aus dem System gelöscht.',
     'users_delete_confirm' => 'Bist Du sicher, dass Du diesen Benutzer löschen möchtest?',
-    'users_delete_success' => 'Benutzer erfolgreich gelöscht.',
+    'users_migrate_ownership' => 'Besitz migrieren',
+    'users_migrate_ownership_desc' => 'Wählen Sie hier einen Benutzer, wenn Sie möchten, dass ein anderer Benutzer der Besitzer aller Einträge wird, die diesem Benutzer derzeit gehören.',
+    'users_none_selected' => 'Kein Benutzer ausgewählt',
+    'users_delete_success' => 'Benutzer erfolgreich entfernt',
     'users_edit' => 'Benutzer bearbeiten',
     'users_edit_profile' => 'Profil bearbeiten',
     'users_edit_success' => 'Benutzer erfolgreich aktualisisert',
@@ -160,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',
@@ -188,6 +242,9 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bulgarisch',
+        'bs' => 'Bosanski',
+        'ca' => 'Katalanisch',
         'cs' => 'Česky',
         'da' => 'Dänisch',
         'de' => 'Deutsch (Sie)',
@@ -195,13 +252,19 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
         'fr' => 'Français',
-        'he' => 'Hebräisch',
+        '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',
index 4be38446889ce05c2ded5504e498f23083fdd215..7eb385ac9e53b2a62094a1eeaf281e2932557e5d 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'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute muss ein Datum vor :date sein.',
     'between'              => [
         'numeric' => ':attribute muss zwischen :min und :max liegen.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute muss mindestens :min Zeichen lang sein.',
         'array'   => ':attribute muss mindesten :min Elemente enthalten.',
     ],
-    'no_double_extension'  => ':attribute darf nur eine gültige Dateiendung',
     'not_in'               => ':attribute ist ungültig.',
     'not_regex'            => ':attribute ist kein gültiges Format.',
     'numeric'              => ':attribute muss eine Zahl sein.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute ist erforderlich, wenn :values nicht vorhanden ist.',
     'required_without_all' => ':attribute ist erforderlich, wenn :values nicht vorhanden sind.',
     'same'                 => ':attribute und :other müssen übereinstimmen.',
+    'safe_url'             => 'Der angegebene Link ist möglicherweise nicht sicher.',
     'size'                 => [
         'numeric' => ':attribute muss :size sein.',
         'file'    => ':attribute muss :size Kilobytes groß sein.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute muss eine Zeichenkette sein.',
     'timezone'             => ':attribute muss eine valide zeitzone sein.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     '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 4cac54b2a706efa35cb8873ebc247c20420e65b5..50bda60bd294e2070a365b30047a0774aade83ae 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'deleted bookshelf',
     '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',
+
     // Other
     'commented_on'                => 'commented on',
+    'permissions_update'          => 'updated permissions',
 ];
index d64fce93a62d90889b2297a9e4f6482ad9046475..e4d4c425b84cf9e0516755c21c0d6ce19c791937 100644 (file)
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Password set, you now have access to :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.',
 ];
\ No newline at end of file
index 68c58b92ba4e9243fd1afae7eca30ed89feab4df..f93fb034bf7e4093ef54ff1878af7bfda3bbb0cd 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Copy',
     'reply' => 'Reply',
     'delete' => 'Delete',
+    'delete_confirm' => 'Confirm Deletion',
     'search' => 'Search',
     'search_clear' => 'Clear Search',
     'reset' => 'Reset',
     'remove' => 'Remove',
     'add' => 'Add',
+    'configure' => 'Configure',
     'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => 'Sort Options',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Sort Ascending',
     'sort_descending' => 'Sort Descending',
     'sort_name' => 'Name',
+    'sort_default' => 'Default',
     'sort_created_at' => 'Created Date',
     'sort_updated_at' => 'Updated Date',
 
@@ -54,6 +61,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,7 @@ return [
     'breadcrumb' => 'Breadcrumb',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profile Menu',
     'view_profile' => 'View Profile',
     'edit_profile' => 'Edit Profile',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Content',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // 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',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
 ];
index 32667eb4eb66ab5bdd8edfe68ac3dad05a573ddd..48a0a32faa38c4821a9d71dda9a5fb4f97d35232 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Load More',
     'image_image_name' => 'Image Name',
     'image_delete_used' => 'This image is used in the pages below.',
-    'image_delete_confirm' => 'Click delete again to confirm you want to delete this image.',
+    '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',
index bb5c0078dcb959274e464a02783a686733d23b88..0a4068eea9f24e2b4b7a8dbaecbabbdb5b3fe099 100644 (file)
@@ -22,10 +22,13 @@ return [
     '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',
+    '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',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Contained Web File',
     'export_pdf' => 'PDF File',
     'export_text' => 'Plain Text File',
+    'export_md' => 'Markdown File',
 
     // 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',
 
     // Search
     'search_results' => 'Search Results',
@@ -58,6 +63,7 @@ return [
     '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',
@@ -93,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.',
@@ -146,7 +153,7 @@ return [
     '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 will be removed and added directly to the parent book.',
+    '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',
@@ -208,6 +215,7 @@ return [
     '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',
     'pages_revisions_number' => '#',
@@ -226,6 +234,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',
@@ -256,7 +265,7 @@ return [
     'attachments_upload' => 'Upload File',
     'attachments_link' => 'Attach Link',
     'attachments_set_link' => 'Set Link',
-    'attachments_delete_confirm' => 'Click delete again to confirm you want to delete this attachment.',
+    '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.',
@@ -265,6 +274,7 @@ return [
     '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',
@@ -312,4 +322,4 @@ return [
     '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
+];
index ec4ce813ea67c3e58d407d753a43bbe6710d2a67..44f0c25a0d600990503d631e9dfc5d401e4e7cd8 100644 (file)
@@ -50,7 +50,6 @@ return [
     'file_upload_timeout' => 'The file upload has timed out.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
     'attachment_not_found' => 'Attachment not found',
 
     // Pages
@@ -88,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 f1345c743b6dcc2bdfc7555774627195ebcd4109..0ab168b66998bca0de4807f94d181b9b12ed1683 100755 (executable)
@@ -37,6 +37,11 @@ return [
     '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.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignore images in revisions',
+    '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!',
@@ -80,6 +85,44 @@ return [
     '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',
+
+    // 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_parent' => 'Parent',
+    '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_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.',
+
+    // 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_ip' => 'IP Address',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
 
     // Role Settings
     'roles' => 'Roles',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
     'role_all' => 'All',
@@ -121,6 +167,7 @@ return [
     '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.',
@@ -138,7 +185,10 @@ return [
     '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_delete_success' => 'Users successfully removed',
+    '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',
@@ -157,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',
@@ -164,7 +218,7 @@ return [
     '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_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',
@@ -172,8 +226,8 @@ return [
     '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_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?',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 76b57a2a3b58ddb8ef41e0562c5187359cc6e542..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'The :attribute must be at least :min characters.',
         'array'   => 'The :attribute must have at least :min items.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
     'not_in'               => 'The selected :attribute is invalid.',
     'not_regex'            => 'The :attribute format is invalid.',
     'numeric'              => 'The :attribute must be a number.',
@@ -90,6 +90,7 @@ return [
     '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.',
     'size'                 => [
         'numeric' => 'The :attribute must be :size.',
         'file'    => 'The :attribute must be :size kilobytes.',
@@ -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 e89b69fd385b337917d22d9468249d183d012c79..a3449269d2ba234374ab6009268b8cee0593c91c 100644 (file)
@@ -43,6 +43,15 @@ 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',
+
     // Other
     'commented_on'                => 'comentada el',
+    'permissions_update'          => 'permisos actualizados',
 ];
index 25fc5b650d2d62eafd641de2a786879b8adad640..70f069a12fe6d44326bcebf03837fffbedece7a7 100644 (file)
@@ -73,5 +73,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!'
+    'user_invite_success' => '¡Contraseña guardada, ya tiene acceso 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.',
 ];
\ No newline at end of file
index 4f4f7f6001ee7663e7a6bb8ff250302582125933..b8514a87628dc30b35cd21744abd4ba510ddbf77 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Copiar',
     'reply' => 'Responder',
     'delete' => 'Borrar',
+    'delete_confirm' => 'Confirmar borrado',
     'search' => 'Buscar',
     'search_clear' => 'Limpiar búsqueda',
     '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',
 
     // Sort Options
     'sort_options' => 'Opciones de ordenación',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Ordenar Ascendentemente',
     'sort_descending' => 'Ordenar Descendentemente',
     'sort_name' => 'Nombre',
+    'sort_default' => 'Predeterminada',
     'sort_created_at' => 'Fecha de Creación',
     'sort_updated_at' => 'Fecha de Modificación',
 
@@ -54,6 +61,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,7 @@ return [
     'breadcrumb' => 'Rastro de migas de pan',
 
     // Header
+    'header_menu_expand' => 'Expandir el Menú de la Cabecera',
     'profile_menu' => 'Menú de Perfil',
     'view_profile' => 'Ver Perfil',
     'edit_profile' => 'Editar Perfil',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Información',
+    'tab_info_label' => 'Pestaña: Mostrar Información Secundaria',
     'tab_content' => 'Contenido',
+    'tab_content_label' => 'Pestaña: Mostrar Contenido Primario',
 
     // Email Content
     'email_action_help' => 'Si está teniendo problemas clicando en el botón ":actionText", copie y pegue la siguiente URL en su navegador web:',
     'email_rights' => 'Todos los derechos reservados',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Política de privacidad',
+    'terms_of_service' => 'Términos de Servicio',
 ];
index f9251991216e07493e3438a2502a30bf5b264bc2..fb4929ad4682313af337e76df503b64342dfdb61 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Cargar más',
     'image_image_name' => 'Nombre de imagen',
     'image_delete_used' => 'Esta imagen está siendo utilizada en las páginas mostradas a continuación.',
-    'image_delete_confirm' => 'Haga click de nuevo para confirmar que quiere borrar esta imagen.',
+    'image_delete_confirm_text' => '¿Estás seguro de que quieres eliminar esta imagen?',
     'image_select_image' => 'Seleccionar Imagen',
     'image_dropzone' => 'Arrastre las imágenes o hacer click aquí para Subir',
     'images_deleted' => 'Imágenes borradas',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Editar Código',
     'code_language' => 'Lenguaje del Código',
     'code_content' => 'Contenido del Código',
+    'code_session_history' => 'Historial de la sesión',
     'code_save' => 'Guardar Código',
 ];
index f67a0a3a3a68ad208a34bfd31fe384b995a08adc..3325557b7860b30625fb8446355ff07d0b197909 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',
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Creado :timeLength por :user',
     'meta_updated' => 'Actualizado :timeLength',
     'meta_updated_name' => 'Actualizado :timeLength por :user',
+    'meta_owned_name' => 'Propiedad de :user',
     'entity_select' => 'Seleccione entidad',
     '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',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Archivo web',
     'export_pdf' => 'Archivo PDF',
     'export_text' => 'Archivo de texto',
+    'export_md' => 'Archivo Markdown',
 
     // Permissions and restrictions
     'permissions' => 'Permisos',
     'permissions_intro' => 'Una vez habilitado, estos permisos tendrán prioridad por encima de cualquier permiso establecido.',
     'permissions_enable' => 'Habilitar permisos personalizados',
     'permissions_save' => 'Guardar permisos',
+    'permissions_owner' => 'Propietario',
 
     // Search
     'search_results' => 'Resultados de búsqueda',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Ninguna página encontrada para la búsqueda',
     'search_for_term' => 'Búsqueda por :term',
     'search_more' => 'Más Resultados',
-    'search_filters' => 'Filtros de Búsqueda',
+    'search_advanced' => 'Búsqueda Avanzada',
+    'search_terms' => 'Términos de búsqueda',
     'search_content_type' => 'Tipo de Contenido',
     'search_exact_matches' => 'Coincidencias Exactas',
     'search_tags' => 'Búsquedas Etiquetadas',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Permisos ajustados',
     'search_created_by_me' => 'Creadas por mí',
     'search_updated_by_me' => 'Actualizadas por mí',
+    'search_owned_by_me' => 'De mi propiedad',
     'search_date_options' => 'Opciones de fecha',
     'search_updated_before' => 'Actualizadas antes de',
     'search_updated_after' => 'Actualizadas después de',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Crear nuevo capítulo',
     'chapters_delete' => 'Borrar capítulo',
     'chapters_delete_named' => 'Borrar capítulo :chapterName',
-    'chapters_delete_explain' => 'Esto borrará el capítulo con el nombre \':chapterName\', todas las páginas serán eliminadas y agregadas directamente al libro padre.',
+    'chapters_delete_explain' => 'Esto eliminará el capítulo con el nombre \':chapterName\'. También se eliminarán todas las páginas que existen dentro de este capítulo.',
     'chapters_delete_confirm' => '¿Está seguro de borrar este capítulo?',
     'chapters_edit' => 'Editar capítulo',
     'chapters_edit_named' => 'Editar capítulo :chapterName',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Revisiones de página',
     'pages_revisions_named' => 'Revisiones de página para :pageName',
     'pages_revision_named' => 'Revisión de página para :pageName',
+    'pages_revision_restored_from' => 'Restaurado de #:id; :summary',
     'pages_revisions_created_by' => 'Creado por',
     'pages_revisions_date' => 'Fecha de revisión',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Subir Archivo',
     'attachments_link' => 'Adjuntar Enlace',
     'attachments_set_link' => 'Ajustar Enlace',
-    'attachments_delete_confirm' => 'Haga click en borrar nuevamente para confirmar que quiere borrar este adjunto.',
+    'attachments_delete' => '¿Está seguro de que quiere eliminar este archivo adjunto?',
     'attachments_dropzone' => 'Arrastre ficheros aquí o haga click aquí para adjuntar un fichero',
     'attachments_no_files' => 'No se han subido ficheros',
     'attachments_explain_link' => 'Puede agregar un enlace si prefiere no subir un archivo. Puede ser un enlace a otra página o un enlace a un fichero en la nube.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Enlace a fichero',
     'attachments_link_url_hint' => 'Url del sitio o fichero',
     'attach' => 'Adjuntar',
+    'attachments_insert_link' => 'Añadir enlace al adjunto en la página',
     'attachments_edit_file' => 'Editar fichero',
     'attachments_edit_file_name' => 'Nombre del fichero',
     'attachments_edit_drop_upload' => 'Arrastre a los ficheros o haga click aquí para subir y sobreescribir',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index ce69b6157f3720dedb085b8a3e43064e7b46b7f8..03e712526d4585b9fc4a17843f6ebcb03eff93d0 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'La carga del archivo ha caducado.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Página no coincidente durante la subida del adjunto ',
     'attachment_not_found' => 'No se encontró el adjunto',
 
     // Pages
@@ -84,6 +83,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 7f249b04e538b97c75fc251c432c138a980eaa1e..bf9c89a63c79a99a320223ce1504e2abac676989 100644 (file)
@@ -13,12 +13,12 @@ return [
 
     // App Settings
     'app_customization' => 'Personalización',
-    'app_features_security' => 'Características & Seguridad',
+    'app_features_security' => 'Características y seguridad',
     'app_name' => 'Nombre de la aplicación',
-    'app_name_desc' => 'Este nombre se muestra en la cabecera y en cualquier correo electrónico',
-    'app_name_header' => 'Mostrar el nombre de la aplicación en la cabecera',
-    'app_public_access' => 'Acceso Público',
-    'app_public_access_desc' => 'Activando esta opción permitirá que usuarios sin iniciar sesión puedan ver el contenido de tu aplicación Bookstack.',
+    'app_name_desc' => 'Este nombre se muestra en la cabecera y en cualquier correo electrónico enviado por el sistema.',
+    'app_name_header' => 'Mostrar nombre en la cabecera',
+    'app_public_access' => 'Acceso público',
+    'app_public_access_desc' => 'Activar esta opción permitirá a los visitantes que no hayan iniciado sesión, poder ver el contenido de tu BookStack.',
     'app_public_access_desc_guest' => 'El acceso público para visitantes puede ser controlado a través del usuario "Guest".',
     'app_public_access_toggle' => 'Permitir acceso público',
     'app_public_viewing' => '¿Permitir acceso público?',
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Página de inicio',
     'app_homepage_desc' => 'Elija la vista que se mostrará en la página de inicio en lugar de la vista predeterminada. Se ignorarán los permisos de la página seleccionada.',
     'app_homepage_select' => 'Elija una página',
+    'app_footer_links' => 'Enlaces de pie de página',
+    'app_footer_links_desc' => 'Añade enlaces para mostrar dentro del pie de página del sitio. Estos se mostrarán en la parte inferior de la mayoría de las páginas, incluyendo aquellas que no requieren estar registrado. Puede utilizar una etiqueta de "trans::<key>" para utilizar traducciones definidas por el sistema. Por ejemplo: el uso de "trans::common.privacy_policy" proporcionará el texto traducido "Política de privacidad" y "trans::common.terms_of_service" proporcionará el texto traducido "Términos de servicio".',
+    'app_footer_links_label' => 'Etiqueta del enlace',
+    'app_footer_links_url' => 'Dirección URL del enlace',
+    'app_footer_links_add' => 'Añadir enlace al pie de página',
     'app_disable_comments' => 'Deshabilitar Comentarios',
     'app_disable_comments_toggle' => 'Deshabilitar comentarios',
     'app_disable_comments_desc' => 'Deshabilita los comentarios en todas las páginas de la aplicación. <br> Los comentarios existentes no se muestran.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignorar imágenes en revisiones',
+    '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?',
     'maint_image_cleanup_success' => '¡Se han encontrado y borrado :count imágenes posiblemente no utilizadas!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Probar correo electrónico',
     'maint_send_test_email_mail_greeting' => '¡El envío de correos electrónicos parece funcionar!',
     'maint_send_test_email_mail_text' => '¡Enhorabuena! Al recibir esta notificación de correo electrónico, tu configuración de correo electrónico parece estar ajustada correctamente.',
+    'maint_recycle_bin_desc' => 'Los estantes, libros, capítulos y páginas eliminados se envían a la papelera de reciclaje para que puedan ser restauradas o eliminadas permanentemente. Los elementos más antiguos en la papelera de reciclaje pueden ser eliminados automáticamente después de un tiempo dependiendo de la configuración del sistema.',
+    'maint_recycle_bin_open' => 'Abrir papelera de reciclaje',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Restaurar',
+    'recycle_bin_contents_empty' => 'La papelera de reciclaje está vacía',
+    'recycle_bin_empty' => 'Vaciar Papelera de reciclaje',
+    'recycle_bin_empty_confirm' => 'Esto destruirá permanentemente todos los elementos de la papelera de reciclaje, incluyendo el contenido existente en cada elemento. ¿Está seguro de que desea vaciar la papelera de reciclaje?',
+    'recycle_bin_destroy_confirm' => 'Esta acción eliminará permanentemente este elemento del sistema, junto con los elementos secundarios listados a continuación, y no podrá restaurar este contenido de nuevo. ¿Está seguro de que desea eliminar permanentemente este elemento?',
+    'recycle_bin_destroy_list' => 'Elementos a eliminar',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Registro de Auditoría',
+    'audit_desc' => 'Este registro de auditoría muestra una lista de actividades registradas en el sistema. Esta lista no está filtrada a diferencia de las listas de actividad similares en el sistema donde se aplican los filtros de permisos.',
+    'audit_event_filter' => 'Filtro de eventos',
+    'audit_event_filter_no_filter' => 'Sin filtro',
+    'audit_deleted_item' => 'Elemento eliminado',
+    'audit_deleted_item_name' => 'Nombre: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Roles',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'A los administradores se les asigna automáticamente permisos para acceder a todo el contenido pero estas opciones podrían mostrar u ocultar opciones de la interfaz.',
     'role_all' => 'Todo',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Perfil de Usuario',
     'users_add_new' => 'Agregar Nuevo Usuario',
     'users_search' => 'Buscar usuarios',
+    'users_latest_activity' => 'Actividad Reciente',
     'users_details' => 'Detalles de Usuario',
     'users_details_desc' => 'Ajusta un nombre público y email para este usuario. El email será empleado para acceder a la aplicación.',
     'users_details_desc_no_email' => 'Ajusta un nombre público para este usuario para que pueda ser reconocido por otros.',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => 'Borrar usuario :userName',
     'users_delete_warning' => 'Se borrará completamente el usuario con el nombre \':userName\' del sistema.',
     'users_delete_confirm' => '¿Está seguro que desea borrar este usuario?',
-    'users_delete_success' => 'Usuarios removidos éxitosamente',
+    'users_migrate_ownership' => 'Cambiar Propietario',
+    'users_migrate_ownership_desc' => 'Seleccione un usuario aquí si desea que otro usuario se convierta en el dueño de todos los elementos que actualmente son propiedad de este usuario.',
+    'users_none_selected' => 'Usuario no seleccionado',
+    'users_delete_success' => 'El usuario se ha eliminado correctamente',
     'users_edit' => 'Editar Usuario',
     'users_edit_profile' => 'Editar perfil',
     'users_edit_success' => 'Usuario actualizado',
@@ -157,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',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Danés',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index de6094a71ccdddd3032dac53982ce85bbaf308aa..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'El :attribute debe ser al menos :min caracteres.',
         'array'   => 'El :attribute debe tener como mínimo :min items.',
     ],
-    'no_double_extension'  => 'El :attribute solo debe tener una extensión de archivo.',
     'not_in'               => 'El :attribute seleccionado es inválio.',
     'not_regex'            => 'El formato de :attribute es inválido.',
     'numeric'              => 'El :attribute debe ser numérico.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'El :attribute es requerido cuando no se encuentre entre los valores :values.',
     'required_without_all' => 'El :attribute es requerido cuando ninguno de los valores :values están presentes.',
     'same'                 => 'El :attribute y :other deben coincidir.',
+    'safe_url'             => 'El enlace proporcionado puede no ser seguro.',
     'size'                 => [
         'numeric' => ':attribute debe ser :size.',
         'file'    => ':attribute debe ser :size kilobytes.',
@@ -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 f8f9e84367568979b8faaca2fda33af9b7d6ebb9..861115fc54604f3b59a317594a51ae4923864ca2 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'Estante borrado',
     'bookshelf_delete_notification'    => 'Estante borrado exitosamente',
 
+    // 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 en Dos Pasos configurado correctamente',
+    'mfa_remove_method_notification' => 'Método de Autenticación en Dos Pasos eliminado correctamente',
+
     // Other
     'commented_on'                => 'comentado',
+    'permissions_update'          => 'permisos actualizados',
 ];
index 8345110413b27748526797f0991d8ff17d8e0a6a..c57b267469582e8c03ef0bf4d960297ca5338198 100644 (file)
@@ -43,7 +43,7 @@ return [
     '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',
     'reset_password_send_button' => 'Enviar enlace de restauración',
-    'reset_password_sent' => 'Un enlace para cambiar la contraseña será enviado a su dirección de correo electrónico si existe en nuestro sistema.',
+    'reset_password_sent' => 'Si la dirección de correo electrónico :email existe en el sistema, se enviará un enlace para restablecer la contraseña.',
     'reset_password_success' => 'Su contraseña se restableció con éxito.',
     'email_reset_subject' => 'Restauración de la contraseña de para la aplicación :appName',
     '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.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Contraseña establecida, ahora tiene acceso 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 de 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 de 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 de 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 de 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.',
 ];
\ No newline at end of file
index 0051e9c2247a2e7a8395fd5f2d7ac21be726079a..05c76cb111c5a2ed11e51bbe7f0af55e0f911500 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Copiar',
     'reply' => 'Responder',
     'delete' => 'Borrar',
+    'delete_confirm' => 'Confirmar eliminación',
     'search' => 'Buscar',
     'search_clear' => 'Limpiar búsqueda',
     'reset' => 'Restablecer',
     'remove' => 'Remover',
     'add' => 'Agregar',
+    'configure' => 'Configurar',
     'fullscreen' => 'Pantalla completa',
+    'favourite' => 'Favoritos',
+    'unfavourite' => 'Eliminar de favoritos',
+    'next' => 'Siguiente',
+    'previous' => 'Anterior',
 
     // Sort Options
     'sort_options' => 'Opciones de Orden',
@@ -46,14 +52,16 @@ return [
     'sort_ascending' => 'Orden Ascendente',
     'sort_descending' => 'Orden Descendente',
     'sort_name' => 'Nombre',
+    'sort_default' => 'Por defecto',
     'sort_created_at' => 'Fecha de creación',
     'sort_updated_at' => 'Fecha de actualización',
 
     // 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,6 +71,7 @@ return [
     'breadcrumb' => 'Miga de Pan',
 
     // Header
+    'header_menu_expand' => 'Expandir el Menú de Cabecera',
     'profile_menu' => 'Menu del Perfil',
     'view_profile' => 'Ver Perfil',
     'edit_profile' => 'Editar Perfil',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Información',
+    'tab_info_label' => 'Pestaña: Mostrar Información Secundaria',
     'tab_content' => 'Contenido',
+    '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:',
     'email_rights' => 'Todos los derechos reservados',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Política de privacidad',
+    'terms_of_service' => 'Términos de Servicio',
 ];
index d205afbc115d94ea6ebe03b1415cb52f841b5113..f7be885898a4ac7b6d5374b2bd55f5afac2f7286 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Cargar más',
     'image_image_name' => 'Nombre de imagen',
     'image_delete_used' => 'Esta imagen esta siendo utilizada en las páginas a continuación.',
-    'image_delete_confirm' => 'Haga click de nuevo para confirmar que quiere borrar esta imagen.',
+    'image_delete_confirm_text' => '¿Está seguro que quiere eliminar esta imagen?',
     'image_select_image' => 'Seleccionar Imagen',
     'image_dropzone' => 'Arrastre las imágenes o hacer click aquí para Subir',
     'images_deleted' => 'Imágenes borradas',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Editar Código',
     'code_language' => 'Lenguaje del Código',
     'code_content' => 'Contenido del Código',
+    'code_session_history' => 'Historial de la sesión',
     'code_save' => 'Guardar Código',
 ];
index 700e873c31d1d0c5786c14867b7b5370116bdc5f..5e71ba266a7b06f0eb22c09f2760ccf64a4b1993 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Creado el  :timeLength por :user',
     'meta_updated' => 'Actualizado el :timeLength',
     'meta_updated_name' => 'Actualizado el :timeLength por :user',
+    'meta_owned_name' => 'Propiedad de :user',
     'entity_select' => 'Seleccione entidad',
     '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',
@@ -33,12 +36,14 @@ 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',
     'permissions_intro' => 'una vez habilitado, Estos permisos tendrán prioridad por encima de cualquier permiso establecido.',
     'permissions_enable' => 'Habilitar permisos custom',
     'permissions_save' => 'Guardar permisos',
+    'permissions_owner' => 'Propietario',
 
     // Search
     'search_results' => 'Buscar resultados',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Ninguna página encontrada para la búsqueda',
     'search_for_term' => 'Busqueda por :term',
     'search_more' => 'Más resultados',
-    'search_filters' => 'Filtros de búsqueda',
+    'search_advanced' => 'Búsqueda Avanzada',
+    'search_terms' => 'Términos de búsqueda',
     'search_content_type' => 'Tipo de contenido',
     'search_exact_matches' => 'Coincidencias exactas',
     'search_tags' => 'Búsquedas de etiquetas',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Permisos establecidos',
     'search_created_by_me' => 'Creado por mí',
     'search_updated_by_me' => 'Actualizado por mí',
+    'search_owned_by_me' => 'De mi propiedad',
     'search_date_options' => 'Opciones de fecha',
     'search_updated_before' => 'Actualizado antes de',
     'search_updated_after' => 'Actualizado después de',
@@ -92,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' => '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 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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Crear nuevo capítulo',
     'chapters_delete' => 'Borrar capítulo',
     'chapters_delete_named' => 'Borrar capítulo :chapterName',
-    'chapters_delete_explain' => 'Esto borrará el capítulo con el nombre \':chapterName\', todas las páginas serán removidas y agregadas directamente al libro padre.',
+    'chapters_delete_explain' => 'Esta acción eliminará el capítulo con el nombre \':chapterName\'. Todas las páginas que existen dentro del capítulo también se eliminarán.',
     'chapters_delete_confirm' => '¿Está seguro de borrar este capítulo?',
     'chapters_edit' => 'Editar capítulo',
     'chapters_edit_named' => 'Editar capítulo :chapterName',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Revisiones de página',
     'pages_revisions_named' => 'Revisiones de página para :pageName',
     'pages_revision_named' => 'Revisión de ágina para :pageName',
+    'pages_revision_restored_from' => 'Restaurado desde #:id; :summary',
     'pages_revisions_created_by' => 'Creado por',
     'pages_revisions_date' => 'Fecha de revisión',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Archivo adjuntado',
     'attachments_link' => 'Adjuntar enlace',
     'attachments_set_link' => 'Establecer enlace',
-    'attachments_delete_confirm' => 'Presione en borrar nuevamente para confirmar que quiere borrar este elemento adjunto.',
+    'attachments_delete' => '¿Está seguro que desea eliminar el archivo adjunto?',
     'attachments_dropzone' => 'Arrastre archivos aquí o presione aquí para adjuntar un archivo',
     'attachments_no_files' => 'No se adjuntó ningún archivo',
     'attachments_explain_link' => 'Usted puede agregar un enlace o si lo prefiere puede agregar un archivo. Esto puede ser un enlace a otra página o un enlace a un archivo en la nube.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Enlace a archivo',
     'attachments_link_url_hint' => 'URL del sitio o archivo',
     'attach' => 'Adjuntar',
+    'attachments_insert_link' => 'Agregar el Enlace del adjunto a la Página',
     'attachments_edit_file' => 'Editar archivo',
     'attachments_edit_file_name' => 'Nombre del archivo',
     'attachments_edit_drop_upload' => 'Arrastre los archivos o presione aquí para subir o sobreescribir',
@@ -275,7 +285,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',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index c640492fecda756ef69eb7b994d8d3ca8ff95522..f96e29db148913798fbc4d181b297b093a796d7b 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'La carga del archivo ha caducado.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Página no coincidente durante la subida del adjunto ',
     'attachment_not_found' => 'No se encuentra el objeto adjunto',
 
     // Pages
@@ -84,6 +83,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 ab386c0d7e5944a2d32ee93c2edf09a39ac0575b..f9e24939b2a130c19385f7eb6c69544dcbd68845 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'La contraseña debe ser como mínimo de seis caracteres y coincidir con la confirmación.',
     'user' => "No podemos encontrar un usuario con esta dirección de correo electrónico.",
-    'token' => 'El token de modificación de contraseña no es válido para esta dirección de correo electrónico.',
+    'token' => 'El token para restablecer la contraseña no es válido para esta dirección de correo electrónico.',
     'sent' => '¡Hemos enviado a su cuenta de correo electrónico un enlace para restaurar su contraseña!',
     'reset' => '¡Su contraseña fue restaurada!',
 
index 9a27eedfdc103a524ad20933a3bf9770ada7c16c..99ec4c219cbab03775834427ba8d6fd99d8fcd64 100644 (file)
@@ -31,12 +31,17 @@ 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',
     'app_homepage_desc' => 'Seleccione una página de inicio para mostrar en lugar de la vista por defecto. Se ignoran los permisos de página para las páginas seleccionadas.',
     'app_homepage_select' => 'Seleccione una página',
+    'app_footer_links' => 'Enlaces de pie de página',
+    'app_footer_links_desc' => 'Añade enlaces para mostrar dentro del pie de página del sitio. Estos se mostrarán en la parte inferior de la mayoría de las páginas, incluyendo aquellas que no requieren estar registrado. Puede utilizar una etiqueta de "trans::<key>" para utilizar traducciones definidas por el sistema. Por ejemplo: el uso de "trans::common.privacy_policy" proporcionará el texto traducido "Política de privacidad" y "trans::common.terms_of_service" proporcionará el texto traducido "Términos de servicio".',
+    'app_footer_links_label' => 'Etiqueta del enlace',
+    'app_footer_links_url' => 'Dirección URL del enlace',
+    'app_footer_links_add' => 'Añadir enlace al pie de página',
     'app_disable_comments' => 'Deshabilitar comentarios',
     'app_disable_comments_toggle' => 'Deshabilitar comentarios',
     'app_disable_comments_desc' => 'Deshabilitar comentarios en todas las páginas de la aplicación. Los comentarios existentes no se muestran.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignorar imágenes en revisión',
+    '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?',
     'maint_image_cleanup_success' => 'Se encontraron y se eliminaron :count imágenes pontencialmente sin uso!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Probar correo electrónico',
     'maint_send_test_email_mail_greeting' => '¡El envío de correos electrónicos parece funcionar!',
     'maint_send_test_email_mail_text' => '¡Enhorabuena! Al recibir esta notificación de correo electrónico, tu configuración de correo electrónico parece estar ajustada correctamente.',
+    'maint_recycle_bin_desc' => 'Los estantes, libros, capítulos y páginas eliminados se envían a la papelera de reciclaje para que puedan ser restauradas o eliminadas permanentemente. Los elementos más antiguos en la papelera de reciclaje pueden ser eliminados automáticamente después de un tiempo dependiendo de la configuración del sistema.',
+    'maint_recycle_bin_open' => 'Abrir papelera de reciclaje',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Restaurar',
+    'recycle_bin_contents_empty' => 'La papelera de reciclaje está vacía',
+    'recycle_bin_empty' => 'Vaciar Papelera de reciclaje',
+    'recycle_bin_empty_confirm' => 'Esto destruirá permanentemente todos los elementos de la papelera de reciclaje, incluyendo el contenido existente en cada elemento. ¿Está seguro de que desea vaciar la papelera de reciclaje?',
+    'recycle_bin_destroy_confirm' => 'Esta acción eliminará permanentemente este elemento del sistema, junto con los elementos secundarios listados a continuación, y no podrá restaurar este contenido. ¿Está seguro de que desea eliminar permanentemente este elemento?',
+    'recycle_bin_destroy_list' => 'Elementos a destruir',
+    '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 elementos de la papelera de reciclaje.',
+    'recycle_bin_restore_notification' => 'Restaurados :count elementos desde la papelera de reciclaje.',
+
+    // Audit Log
+    'audit' => 'Registro de Auditoría',
+    'audit_desc' => 'Este registro de auditoría muestra una lista de actividades rastreadas en el sistema. Esta lista no tiene filtrado a diferencia de listas de actividad similares en el sistema en los que se aplican filtros de permisos.',
+    'audit_event_filter' => 'Filtro de Eventos',
+    'audit_event_filter_no_filter' => 'Sin Filtro',
+    'audit_deleted_item' => 'Elemento borrado',
+    'audit_deleted_item_name' => 'Nombre: :name',
+    'audit_table_user' => 'Usuario',
+    'audit_table_event' => 'Evento',
+    'audit_table_related' => 'Elemento o detalle relacionados',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Fecha de la Actividad',
+    'audit_date_from' => 'Inicio del Rango de Fecha',
+    'audit_date_to' => 'Final del Rango de Fecha',
 
     // Role Settings
     'roles' => 'Roles',
@@ -96,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' => 'IDs de Autenticación Externa',
     'role_system' => 'Permisos de sistema',
     'role_manage_users' => 'Gestionar usuarios',
@@ -106,8 +150,10 @@ 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',
-    'role_asset_desc' => 'Estos permisos controlan el acceso por defecto a los activos del sistema. Permisos a Libros, Capítulos y Páginas sobreescribiran estos permisos.',
+    'roles_system_warning' => 'Tenga en cuenta que el acceso a cualquiera de los tres permisos anteriores puede permitir a un usuario modificar sus propios privilegios o los privilegios de otros usuarios en el sistema. Asignar roles con estos permisos sólo a usuarios de comfianza.',
+    'role_asset_desc' => 'Estos permisos controlan el acceso por defecto a los activos del sistema. Permisos definidos en Libros, Capítulos y Páginas ignorarán estos permisos.',
     'role_asset_admins' => 'Los administradores reciben automáticamente acceso a todo el contenido pero estas opciones pueden mostrar u ocultar opciones de UI.',
     'role_all' => 'Todo',
     'role_own' => 'Propio',
@@ -122,6 +168,7 @@ return [
     'user_profile' => 'Perfil de usuario',
     'users_add_new' => 'Agregar nuevo usuario',
     'users_search' => 'Buscar usuarios',
+    'users_latest_activity' => 'Actividad Reciente',
     'users_details' => 'Detalles del usuario',
     'users_details_desc' => 'Asigne un nombre de visualización y una dirección de correo electrónico para este usuario. La dirección de correo electrónico se usará pra ingresar a la aplicación.',
     'users_details_desc_no_email' => 'Asigne un nombre de visualización a este usuario para que los demás puedan reconocerlo.',
@@ -139,7 +186,10 @@ return [
     'users_delete_named' => 'Borrar usuario :userName',
     'users_delete_warning' => 'Se borrará completamente el usuario con el nombre \':userName\' del sistema.',
     'users_delete_confirm' => '¿Está seguro que desea borrar este usuario?',
-    'users_delete_success' => 'Usuarios removidos exitosamente',
+    'users_migrate_ownership' => 'Cambiar Propietario',
+    'users_migrate_ownership_desc' => 'Seleccione un usuario aquí si desea que otro usuario se convierta en el dueño de todos los elementos que actualmente son propiedad de este usuario.',
+    'users_none_selected' => 'No hay usuario seleccionado',
+    'users_delete_success' => 'El usuario fue eliminado correctamente',
     'users_edit' => 'Editar Usuario',
     'users_edit_profile' => 'Editar perfil',
     'users_edit_success' => 'Usuario actualizado',
@@ -158,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 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',
@@ -165,13 +219,13 @@ return [
     'user_api_token_name_desc' => 'Dale a tu token un nombre legible como un recordatorio futuro de su propósito.',
     'user_api_token_expiry' => 'Fecha de expiración',
     'user_api_token_expiry_desc' => 'Establece una fecha en la que este token expira. Después de esta fecha, las solicitudes realizadas usando este token ya no funcionarán. Dejar este campo en blanco fijará un vencimiento de 100 años en el futuro.',
-    'user_api_token_create_secret_message' => 'Inmediatamente después de crear este token se generarán y mostrarán sus correspondientes "Token ID" y "Token Secret". El "Token Secret" sólo se mostrará una vez, así que asegúrese de copiar el valor a un lugar seguro antes de proceder.',
+    'user_api_token_create_secret_message' => 'Luego de crear este token, inmediatamente se generará y mostrará el "Token ID" y el "Token Secret" correspondientes. El "Token Secret" se mostrará por única vez, asegúrese de copiar el valor a un lugar seguro antes de continuar.',
     'user_api_token_create_success' => 'Token API creado correctamente',
     'user_api_token_update_success' => 'Token API actualizado correctamente',
     'user_api_token' => 'Token API',
     'user_api_token_id' => 'Token ID',
     'user_api_token_id_desc' => 'Este es un identificador no editable generado por el sistema y único para este token que necesitará ser proporcionado en solicitudes de API.',
-    'user_api_token_secret' => 'Token Secret',
+    'user_api_token_secret' => 'Clave de Token',
     'user_api_token_secret_desc' => 'Esta es una clave no editable generada por el sistema que necesitará ser proporcionada en solicitudes de API. Solo se monstraré esta vez así que guarde su valor en un lugar seguro.',
     'user_api_token_created' => 'Token creado :timeAgo',
     'user_api_token_updated' => 'Token actualizado :timeAgo',
@@ -186,6 +240,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Danés',
         'de' => 'Deutsch (Sie)',
@@ -194,12 +251,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index cd360c8eae64e9f987a379366f40b10cf7bbfcc5..2cc8ed9bf442d69a50a06743521f709b5790bbeb 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 ha sido utilizado.',
     'before'               => 'El :attribute debe ser una fecha anterior a  :date.',
     'between'              => [
         'numeric' => 'El :attribute debe estar entre :min y :max.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute debe ser al menos :min caracteres.',
         'array'   => ':attribute debe tener como mínimo :min items.',
     ],
-    'no_double_extension'  => 'El :attribute debe tener una única extensión de archivo.',
     'not_in'               => ':attribute seleccionado es inválido.',
     'not_regex'            => 'El formato de :attribute es inválido.',
     'numeric'              => ':attribute debe ser numérico.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute es requerido cuando no se encuentre entre los valores :values.',
     'required_without_all' => ':attribute es requerido cuando ninguno de los valores :values están presentes.',
     'same'                 => ':attribute y :other deben coincidir.',
+    'safe_url'             => 'El enlace provisto puede ser inseguro.',
     'size'                 => [
         'numeric' => ':attribute debe ser :size.',
         'file'    => ':attribute debe ser :size kilobytes.',
@@ -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 ha expirado.',
     '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.',
index 4cac54b2a706efa35cb8873ebc247c20420e65b5..43b6b4789c9bdb8039d9f12b2c949115c5d55578 100644 (file)
@@ -6,43 +6,52 @@
 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' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
 
     // Other
-    'commented_on'                => 'commented on',
+    'commented_on'                => 'ثبت دیدگاه',
+    'permissions_update'          => 'به روزرسانی مجوزها',
 ];
index d64fce93a62d90889b2297a9e4f6482ad9046475..4f950b77f0482e8e28cfa77333d6821f6b1e18ec 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.',
+    'name' => 'نام',
+    'username' => 'نام کاربری',
+    'email' => 'پست الکترونیک',
+    'password' => 'کلمه عبور',
+    'password_confirm' => 'تایید کلمه عبور',
+    'password_hint' => 'باید بیش از 7 کاراکتر باشد',
+    'forgot_password' => 'کلمه عبور خود را فراموش کرده اید؟',
+    'remember_me' => 'مرا به خاطر بسپار',
+    'ldap_email_hint' => 'لطفا برای استفاده از این حساب کاربری پست الکترونیک وارد نمایید.',
+    'create_account' => 'ایجاد حساب کاربری',
+    'already_have_account' => 'قبلا ثبت نام نموده اید؟',
+    'dont_have_account' => 'حساب کاربری ندارید؟',
+    'social_login' => 'ورود از طریق شبکه اجتماعی',
+    'social_registration' => 'ثبت نام از طریق شبکه اجتماعی',
+    'social_registration_text' => 'با استفاده از سرویس دیگری ثبت نام نموده و وارد سیستم شوید.',
 
-    '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.',
+    '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!'
+    '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' => 'کلمه عبور تنظیم شده است، شما اکنون به :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' => '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.',
 ];
\ No newline at end of file
index 68c58b92ba4e9243fd1afae7eca30ed89feab4df..6d3768de4679382896f7675a7744aa45676b91f5 100644 (file)
@@ -5,75 +5,91 @@
 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',
-    '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' => 'Configure',
+    'fullscreen' => 'تمام صفحه',
+    'favourite' => 'علاقه‌مندی',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'بعدی',
+    'previous' => 'قبلى',
 
     // Sort Options
-    'sort_options' => 'Sort Options',
-    'sort_direction_toggle' => 'Sort Direction Toggle',
-    'sort_ascending' => 'Sort Ascending',
-    'sort_descending' => 'Sort Descending',
-    'sort_name' => 'Name',
-    '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' => 'مسیر جاری',
 
     // Header
-    '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_content' => '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' => 'سیاست حفظ حریم خصوصی',
+    'terms_of_service' => 'شرایط خدمات',
 ];
index d8e8981fb5fcf6ba8d15993453d4f8f2d07df970..126bd093db9854d58c2d6b2718b86b605d4e996a 100644 (file)
@@ -5,29 +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' => 'Click delete again to confirm 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_save' => 'Save Code',
+    'code_editor' => 'ویرایش کد',
+    'code_language' => 'زبان کد',
+    'code_content' => 'محتوی کد',
+    'code_session_history' => 'تاریخچه جلسات',
+    'code_save' => 'ذخیره کد',
 ];
index 6bbc723b0abfc1e6b1f69e270bbb9d2afdbe851b..3d45e2165757defed65c03655f2facf6fc7dbd82 100644 (file)
@@ -6,57 +6,64 @@
 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',
-    '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' => 'مجوزها',
+    '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_filters' => 'Search Filters',
-    '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_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' => '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',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     '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 will be removed and added directly to the parent book.',
+    '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',
@@ -207,6 +215,7 @@ return [
     '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',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Upload File',
     'attachments_link' => 'Attach Link',
     'attachments_set_link' => 'Set Link',
-    'attachments_delete_confirm' => 'Click delete again to confirm you want to delete this attachment.',
+    '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.',
@@ -264,6 +273,7 @@ return [
     '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',
@@ -311,4 +321,4 @@ return [
     '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
+];
index 06a5285f56fc4ce11e6642549a1002b1bacae698..9b0281bc0a05707955bc22b5fbe4ad6072f84511 100644 (file)
@@ -5,34 +5,34 @@
 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.',
+    '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 انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد',
+    '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' => 'This invitation link has expired. You can instead try to reset your account password.',
 
     // System
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'The file upload has timed out.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
     'attachment_not_found' => 'Attachment not found',
 
     // Pages
@@ -84,6 +83,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 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 f1345c743b6dcc2bdfc7555774627195ebcd4109..0ab168b66998bca0de4807f94d181b9b12ed1683 100644 (file)
@@ -37,6 +37,11 @@ return [
     '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.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignore images in revisions',
+    '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!',
@@ -80,6 +85,44 @@ return [
     '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',
+
+    // 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_parent' => 'Parent',
+    '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_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.',
+
+    // 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_ip' => 'IP Address',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
 
     // Role Settings
     'roles' => 'Roles',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
     'role_all' => 'All',
@@ -121,6 +167,7 @@ return [
     '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.',
@@ -138,7 +185,10 @@ return [
     '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_delete_success' => 'Users successfully removed',
+    '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',
@@ -157,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',
@@ -164,7 +218,7 @@ return [
     '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_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',
@@ -172,8 +226,8 @@ return [
     '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_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?',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 76b57a2a3b58ddb8ef41e0562c5187359cc6e542..cb9cd8eff023f76740b98df21912b0761987446c 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'         => '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.',
-        '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 آیتم داشته باشد.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
-    '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.',
+    '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'                 => 'The provided code is not valid or has expired.',
+    'unique'               => ':attribute قبلا انتخاب شده است.',
+    'url'                  => ':attribute معتبر نمی‌باشد.',
+    'uploaded'             => 'بارگذاری فایل :attribute موفقیت آمیز نبود.',
 
     // Custom validation lines
     'custom' => [
         'password-confirm' => [
-            'required_with' => 'Password confirmation required',
+            'required_with' => 'تایید کلمه عبور اجباری می باشد',
         ],
     ],
 
index 56db4abff0490f3c68e97b24933aa925e2b2b586..9850bc93cd23068bd5d14f23987dd5594323a325 100644 (file)
@@ -26,11 +26,11 @@ 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',
@@ -43,6 +43,15 @@ 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',
+
     // Other
     'commented_on'                => 'a commenté',
+    'permissions_update'          => 'a mis à jour les autorisations sur',
 ];
index 07252420a030a8b87fc3e1b3eb9de8e41c389f78..c608f02fb043359e3d8bd7f05edbf490739b8a2a 100644 (file)
@@ -57,13 +57,13 @@ return [
     '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_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 +73,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 !'
+    'user_invite_success' => 'Mot de passe renseigné, vous avez maintenant accès à :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.',
 ];
\ No newline at end of file
index 81dde84c47dc1f6be7651b1c93048bab58429eb6..e6e8bf1a272100e5c92e481de4292e1edc7f5bff 100644 (file)
@@ -27,18 +27,24 @@ return [
     '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',
-    'search' => 'Chercher',
+    'delete_confirm' => 'Confirmer la suppression',
+    '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',
 
     // Sort Options
     'sort_options' => 'Options de tri',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Tri ascendant',
     'sort_descending' => 'Tri descendant',
     'sort_name' => 'Nom',
+    'sort_default' => 'Défaut',
     'sort_created_at' => 'Date de création',
     'sort_updated_at' => 'Date de mise à jour',
 
@@ -54,6 +61,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,6 +71,7 @@ return [
     'breadcrumb' => 'Fil d\'Ariane',
 
     // Header
+    'header_menu_expand' => 'Développer le menu',
     'profile_menu' => 'Menu du profil',
     'view_profile' => 'Voir le profil',
     'edit_profile' => 'Modifier le profil',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Informations',
+    'tab_info_label' => 'Onglet : Afficher les informations secondaires',
     'tab_content' => 'Contenu',
+    '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 :',
     'email_rights' => 'Tous droits réservés',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Politique de confidentialité',
+    'terms_of_service' => 'Conditions d\'utilisation',
 ];
index 2f6ff8bf9c906413e5a679c4943b6364d9ea8633..fed157a4793ff589ffc975026859b157c2062a96 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Charger plus',
     'image_image_name' => 'Nom de l\'image',
     'image_delete_used' => 'Cette image est utilisée dans les pages ci-dessous.',
-    'image_delete_confirm' => 'Confirmez que vous souhaitez bien supprimer cette image.',
+    'image_delete_confirm_text' => 'Êtes-vous sûr de vouloir supprimer cette image ?',
     'image_select_image' => 'Sélectionner l\'image',
     'image_dropzone' => 'Glissez les images ici ou cliquez pour les ajouter',
     'images_deleted' => 'Images supprimées',
@@ -26,8 +26,9 @@ 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',
     'code_save' => 'Enregistrer le code',
 ];
index d52dbfda348dc4842388531caa33413d2c538b9c..1ae697c405321fafa42cac7247b76d417fc8fffd 100644 (file)
@@ -15,17 +15,20 @@ return [
     'recently_update' => 'Mis à jour récemment',
     'recently_viewed' => 'Vus récemment',
     'recent_activity' => 'Activité récente',
-    'create_now' => 'En créer un maintenant',
+    'create_now' => 'En créer une maintenant',
     'revisions' => 'Révisions',
     'meta_revision' => 'Révision #:revisionCount',
     'meta_created' => 'Créé :timeLength',
     'meta_created_name' => 'Créé :timeLength par :user',
     'meta_updated' => 'Mis à jour :timeLength',
     'meta_updated_name' => 'Mis à jour :timeLength 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',
@@ -33,12 +36,14 @@ 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',
 
     // Search
     'search_results' => 'Résultats de recherche',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Aucune page correspondant à cette recherche',
     'search_for_term' => 'recherche pour :term',
     'search_more' => 'Plus de résultats',
-    'search_filters' => 'Filtres de recherche',
+    'search_advanced' => 'Recherche avancée',
+    'search_terms' => 'Mot-clé',
     'search_content_type' => 'Type de contenu',
     'search_exact_matches' => 'Correspondances exactes',
     'search_tags' => 'Recherche par tags',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Ensemble d\'autorisations',
     'search_created_by_me' => 'Créé par moi',
     'search_updated_by_me' => 'Mis à jour par moi',
+    'search_owned_by_me' => 'Créés par moi',
     'search_date_options' => 'Recherche par date',
     'search_updated_before' => 'Mis à jour avant',
     'search_updated_after' => 'Mis à jour après',
@@ -73,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',
@@ -87,11 +94,12 @@ return [
     'shelves_edit' => 'Modifier l\'étagère',
     'shelves_delete' => 'Supprimer l\'étagère',
     'shelves_delete_named' => 'Supprimer l\'étagère :name',
-    'shelves_delete_explain' => "Ceci va supprimer l\\'étagère nommée \\':bookName\\'. Les livres contenus dans cette étagère ne seront pas supprimés.",
+    'shelves_delete_explain' => "Ceci va supprimer l'étagère nommée ':name'. Les livres contenus dans cette étagère ne seront pas supprimés.",
     'shelves_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer cette étagère ?',
     '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.',
@@ -124,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',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Créer un nouveau chapitre',
     'chapters_delete' => 'Supprimer le chapitre',
     'chapters_delete_named' => 'Supprimer le chapitre :chapterName',
-    'chapters_delete_explain' => 'Ceci va supprimer le chapitre \':chapterName\', toutes les pages seront déplacées dans le livre parent.',
+    'chapters_delete_explain' => 'Ceci supprimera le chapitre portant le nom \':chapterName\'. Toutes les pages qui existent dans ce chapitre seront également supprimées.',
     'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre ?',
     'chapters_edit' => 'Modifier le chapitre',
     'chapters_edit_named' => 'Modifier le chapitre :chapterName',
@@ -166,7 +174,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',
@@ -181,16 +189,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',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Révisions de la page',
     'pages_revisions_named' => 'Révisions pour :pageName',
     'pages_revision_named' => 'Révision pour :pageName',
+    'pages_revision_restored_from' => 'Restauré à partir de #:id; :summary',
     'pages_revisions_created_by' => 'Créé par',
     'pages_revisions_date' => 'Date de révision',
     'pages_revisions_number' => '#',
@@ -214,7 +223,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',
@@ -223,8 +232,8 @@ 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_edit_active' => [
         'start_a' => ':count utilisateurs ont commencé à éditer cette page',
         'start_b' => ':userName a commencé à éditer cette page',
@@ -233,7 +242,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
@@ -244,10 +253,10 @@ 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é',
     '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.',
@@ -255,15 +264,16 @@ return [
     'attachments_upload' => 'Uploader un fichier',
     'attachments_link' => 'Attacher un lien',
     'attachments_set_link' => 'Définir un lien',
-    'attachments_delete_confirm' => 'Cliquer une seconde fois sur supprimer pour valider la suppression.',
+    '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',
+    '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',
@@ -278,7 +288,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',
@@ -303,7 +313,7 @@ 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
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index 2c697e67df9d21adc094981e865d33babe89e65e..d7f00d8e1891b149ad4615ecf73b2f077a9be42f 100644 (file)
@@ -16,24 +16,24 @@ 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',
     '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,15 +42,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_page_mismatch' => 'Page incorrecte durant la mise à jour du fichier joint',
     '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
@@ -61,10 +60,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
@@ -75,7 +74,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.',
@@ -83,7 +82,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',
@@ -94,7 +96,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 d2096ed75530d054d546d5c22e97a6b4ef8ecc83..aa58aeee080d4016964876fa3e85faeb7c9ca760 100644 (file)
@@ -21,22 +21,27 @@ 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.',
     'app_homepage' => 'Page d\'accueil de l\'application',
     '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' => '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',
     'app_disable_comments' => 'Désactiver les commentaires',
     'app_disable_comments_toggle' => 'Désactiver les commentaires',
     'app_disable_comments_desc' => 'Désactive les commentaires sur toutes les pages de l\'application. Les commentaires existants ne sont pas affichés.',
@@ -44,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',
@@ -67,19 +72,57 @@ 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_ignore_revisions' => 'Ignorer les images dans les révisions',
+    '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',
+
+    // Recycle Bin
+    '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_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.',
+
+    // Audit Log
+    'audit' => 'Journal d\'audit',
+    'audit_desc' => 'Ce journal d\'audit affiche une liste des activités suivies dans le système. Cette liste n\'est pas filtrée contrairement aux listes d\'activités similaires dans le système où les filtres d\'autorisation sont appliqués.',
+    'audit_event_filter' => 'Filtres d\'événement',
+    'audit_event_filter_no_filter' => 'Pas de filtre',
+    'audit_deleted_item' => 'Élément supprimé',
+    'audit_deleted_item_name' => 'Nom: :name',
+    'audit_table_user' => 'Utilisateur',
+    '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',
 
     // Role Settings
     'roles' => 'Rôles',
@@ -96,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',
@@ -105,7 +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. 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',
@@ -120,25 +166,29 @@ 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.',
     'users_details_desc_no_email' => 'Définissez un nom d\'affichage pour cet utilisateur afin que les autres puissent le reconnaître.',
     '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 utilisé pour vous connecter à l\'application. Il doit comporter au moins 6 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_delete_success' => 'Utilisateurs supprimés avec succès',
+    '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électionné',
+    'users_delete_success' => 'Utilisateur supprimé avec succès',
     'users_edit' => 'Modifier l\'utilisateur',
     'users_edit_profile' => 'Modifier le profil',
     'users_edit_success' => 'Utilisateur mis à jour avec succès',
@@ -146,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',
@@ -165,19 +219,19 @@ 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',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bulgare',
+        'bs' => 'Bosanski',
+        'ca' => 'Catalan',
         'cs' => 'Česky',
         'da' => 'Danois',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
         'pl' => 'Polski',
+        'pt' => 'Português',
         'pt_BR' => 'Português do Brasil',
         'ru' => 'Русский',
         'sk' => 'Slovensky',
index f59d5c50313c3fb1ea3533b0d27be85e62b70ffb..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.',
     ],
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute doit contenir au moins :min caractères.',
         'array'   => ':attribute doit contenir au moins :min éléments.',
     ],
-    'no_double_extension'  => ':attribute ne doit avoir qu\'une seule extension de fichier.',
     'not_in'               => 'L\'attribut sélectionné :attribute est invalide.',
     'not_regex'            => ':attribute a un format invalide.',
     'numeric'              => ':attribute doit être un nombre.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute est requis si:values n\'est pas présent.',
     'required_without_all' => ':attribute est requis si aucun des valeurs :values n\'est présente.',
     'same'                 => ':attribute et :other doivent être identiques.',
+    'safe_url'             => 'Le lien fourni peut ne pas être sûr.',
     'size'                 => [
         'numeric' => ':attribute doit avoir la taille :size.',
         'file'    => ':attribute doit peser :size kilobytes.',
@@ -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 34860173e0d6106e341134b493b45403889405c7..c19825afe3458404ea9fd973d4dde1ecd44745c3 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'deleted bookshelf',
     'bookshelf_delete_notification'    => 'מדף הספרים הוסר בהצלחה',
 
+    // 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',
+
     // Other
     'commented_on'                => 'commented on',
+    'permissions_update'          => 'updated permissions',
 ];
index 733c84f9de45bca28b12d202924152ce72b7676e..a078cfcf8bc0eb515e74f3d6fe5b080f5711f40f 100644 (file)
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Password set, you now have access to :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.',
 ];
\ No newline at end of file
index 0dc1cc43dcdc2c93c24a32a2e899328c0f9b7e74..475987f342ce9a4e92c2d026f614ab06bc65f97b 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'העתק',
     'reply' => 'השב',
     'delete' => 'מחק',
+    'delete_confirm' => 'Confirm Deletion',
     'search' => 'חיפוש',
     'search_clear' => 'נקה חיפוש',
     'reset' => 'איפוס',
     'remove' => 'הסר',
     'add' => 'הוסף',
+    'configure' => 'Configure',
     'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => 'Sort Options',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Sort Ascending',
     'sort_descending' => 'Sort Descending',
     'sort_name' => 'שם',
+    'sort_default' => 'Default',
     'sort_created_at' => 'תאריך יצירה',
     'sort_updated_at' => 'תאריך עדכון',
 
@@ -54,6 +61,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,7 @@ return [
     'breadcrumb' => 'Breadcrumb',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profile Menu',
     'view_profile' => 'הצג פרופיל',
     'edit_profile' => 'ערוך פרופיל',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'מידע',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'תוכן',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => 'אם לא ניתן ללחות על כפתור ״: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',
 ];
index 73dbe4e18ac65f419c5cc3bb6b02bc0c70d7c721..48f7aa2d25af1c67f6b6866780b736357de4c214 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'טען עוד',
     'image_image_name' => 'שם התמונה',
     'image_delete_used' => 'תמונה זו בשימוש בדפים שמתחת',
-    'image_delete_confirm' => 'לחץ ״מחק״ שוב על מנת לאשר שברצונך למחוק תמונה זו',
+    'image_delete_confirm_text' => 'האם את/ה בטוח/ה שברצונך למחוק את התמונה הזו?',
     'image_select_image' => 'בחר תמונה',
     'image_dropzone' => 'גרור תמונות או לחץ כאן להעלאה',
     'images_deleted' => 'התמונות נמחקו',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'ערוך קוד',
     'code_language' => 'שפת הקוד',
     'code_content' => 'תוכן הקוד',
+    'code_session_history' => 'היסטורית ה-Session',
     'code_save' => 'שמור קוד',
 ];
index 2fb0e82ec663d7ee3438b45bf1fd49f7e45afc9e..23aa50158c718c529cf343df724bf6decb0897c4 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'נוצר :timeLength על ידי :user',
     'meta_updated' => 'עודכן :timeLength',
     'meta_updated_name' => 'עודכן :timeLength על ידי :user',
+    'meta_owned_name' => 'Owned by :user',
     'entity_select' => 'בחר יישות',
     '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' => 'לא עודכנו דפים לאחרונה',
@@ -33,12 +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',
 
     // Search
     'search_results' => 'תוצאות חיפוש',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'לא נמצאו דפים התואמים לחיפוש',
     'search_for_term' => 'חפש את :term',
     'search_more' => 'תוצאות נוספות',
-    'search_filters' => 'מסנני חיפוש',
+    'search_advanced' => 'Advanced Search',
+    'search_terms' => 'Search Terms',
     'search_content_type' => 'סוג התוכן',
     'search_exact_matches' => 'התאמות מדויקות',
     'search_tags' => 'חפש בתגים',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'סט הרשאות',
     'search_created_by_me' => 'שנוצרו על ידי',
     'search_updated_by_me' => 'שעודכנו על ידי',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => 'אפשרויות תאריך',
     'search_updated_before' => 'שעודכנו לפני',
     'search_updated_after' => 'שעודכנו לאחר',
@@ -92,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' => 'פעולה זו תעתיק את כל הרשאות המדף לכל הספרים המשוייכים למדף זה. לפני הביצוע, יש לוודא שכל הרשאות המדף אכן נשמרו.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'צור פרק חדש',
     'chapters_delete' => 'מחק פרק',
     'chapters_delete_named' => 'מחק את פרק :chapterName',
-    'chapters_delete_explain' => 'פעולה זו תמחוק את הפרק בשם \':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' => 'האם ברצונך למחוק פרק זה?',
     'chapters_edit' => 'ערוך פרק',
     'chapters_edit_named' => 'ערוך פרק :chapterName',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'נוסחי דף',
     'pages_revisions_named' => 'נוסחי דף עבור :pageName',
     'pages_revision_named' => 'נוסח דף עבור :pageName',
+    'pages_revision_restored_from' => 'Restored from #:id; :summary',
     'pages_revisions_created_by' => 'נוצר על ידי',
     'pages_revisions_date' => 'תאריך נוסח',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'העלה קובץ',
     'attachments_link' => 'צרף קישור',
     'attachments_set_link' => 'הגדר קישור',
-    'attachments_delete_confirm' => 'יש ללחוץ שוב על מחיקה על מנת לאשר את מחיקת הקובץ המצורף',
+    'attachments_delete' => 'Are you sure you want to delete this attachment?',
     'attachments_dropzone' => 'גרור לכאן קבצים או לחץ על מנת לצרף קבצים',
     'attachments_no_files' => 'לא הועלו קבצים',
     'attachments_explain_link' => 'ניתן לצרף קישור במקום העלאת קובץ, קישור זה יכול להוביל לדף אחר או לכל קובץ באינטרנט',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'קישור לקובץ',
     'attachments_link_url_hint' => 'כתובת האתר או הקובץ',
     'attach' => 'צרף',
+    'attachments_insert_link' => 'Add Attachment Link to Page',
     'attachments_edit_file' => 'ערוך קובץ',
     'attachments_edit_file_name' => 'שם הקובץ',
     'attachments_edit_drop_upload' => 'גרור קבצים או לחץ כאן על מנת להעלות קבצים במקום הקבצים הקיימים',
@@ -311,4 +321,4 @@ return [
     'revision_restore_confirm' => 'האם ברצונך לשחזר נוסח זה? תוכן הדף הנוכחי יעודכן לנוסח זה.',
     'revision_delete_success' => 'נוסח נמחק',
     'revision_cannot_delete_latest' => 'לא ניתן למחוק את הנוסח האחרון'
-];
\ No newline at end of file
+];
index 9920f1b9880b8b61b1150dd897b4a3ea1c268662..5c879216c234006ce0480904a0bb3a868ccc8768 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'The file upload has timed out.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
     'attachment_not_found' => 'קובץ מצורף לא נמצא',
 
     // Pages
@@ -84,6 +83,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 17fad68d5baf120215751986c10ce194b19e6678..a94d7c30b36449e94a5a64a5497383c28c25ff9e 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => '×\94ס×\99ס×\9e×\90 ×\97×\99×\99×\91ת ×\9c×\94×\99×\95ת ×\91×¢×\9cת 6 ×ª×\95×\95×\99×\9d ×\95×\9c×\94ת×\90×\99×\9d ×\9c×\90×\99×\9e×\95ת',
-    'user' => "×\9c×\90 × ×\99ת×\9f ×\9c×\9eצ×\95×\90 ×\9eשת×\9eש ×¢×\9d ×\94×\9e×\99×\99×\9c ×©×¡×\95פק",
-    'token' => 'The password reset token is invalid for this email address.',
-    'sent' => 'נש×\9c×\97 ×\90×\9c×\99×\9a ×\90×\99\9e×\99×\99×\9c ×¢×\9d ×§×\99ש×\95ר ×\9c×\90×\99פ×\95ס ×\94ס×\99ס×\9e×\90',
-    'reset' => '×\90×\99פ×\95ס ×\94ס×\99ס×\9e×\90 הושלם בהצלחה!',
+    'password' => '×\94ס×\99ס×\9e×\94 ×\97×\99×\99×\91ת ×\9c×\94×\99×\95ת ×\91×¢×\9cת 8 ×ª×\95×\95×\99×\9d ×\9cפ×\97×\95ת ×\95×\9c×\94ת×\90×\99×\9d ×\9c×\90×\99×\9e×\95ת.',
+    'user' => "×\9c×\90 × ×\9eצ×\90 ×\9eשת×\9eש ×¢×\9d ×\9bת×\95×\91ת ×\93×\95×\90\"×\9c ×\96×\95.",
+    'token' => 'אסימון איפוס הסיסמה לא תקף עבור כתובת דוא"ל זו.',
+    'sent' => 'ש×\9c×\97× ×\95 ×\9c×\9a ×\93×\95×\90\9c ×¢×\9d ×§×\99ש×\95ר ×\9c×\90×\99פ×\95ס ×\94ס×\99ס×\9e×\94!',
+    'reset' => '×\90×\99פ×\95ס ×\94ס×\99ס×\9e×\94 הושלם בהצלחה!',
 
 ];
index e3cd0ead5085514c089b671e5dd9b7054099827e..e158867797e8b89fb8e5134b135ebb570c0a7a0b 100755 (executable)
@@ -37,18 +37,23 @@ return [
     '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' => 'מבטל את התגובות לאורך כל היישום, תגובות קיימות לא יוצגו.',
 
     // 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' => 'הרשמה',
@@ -56,7 +61,7 @@ return [
     'reg_enable_toggle' => 'אפשר להרשם',
     'reg_enable_desc' => 'כאשר אפשר להרשם משתמשים יוכלו להכנס באופן עצמאי. בעת ההרשמה המשתמש יקבל הרשאה יחידה כברירת מחדל.',
     'reg_default_role' => 'הרשאה כברירת מחדל',
-    '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' => 'האפשרות לעיל חסרת השפעה כאשר מתבצע שימוש באותנטיקציה חיצונית מסוג LDAP או SAML. חשבונות משתמש לחברים לא קיימים יווצרו באופן אוטומטי במידה ואותנטיקציה, הנוגדת את המערכת החיצונית בשימוש, מצליחה.',
     'reg_email_confirmation' => 'אימות כתובת אי-מייל',
     'reg_email_confirmation_toggle' => 'יש לאמת את כתובת המייל',
     'reg_confirm_email_desc' => 'אם מופעלת הגבלה לדומיין ספציפי אז אימות המייל לא יבוצע',
@@ -68,18 +73,56 @@ return [
     'maint' => 'תחזוקה',
     'maint_image_cleanup' => 'ניקוי תמונות',
     'maint_image_cleanup_desc' => "סורק את הדפים והגרסאות על מנת למצוא אילו תמונות לא בשימוש. יש לוודא גיבוי מלא של מסד הנתונים והתמונות לפני הרצה",
-    'maint_image_cleanup_ignore_revisions' => 'התעלם מהתמונות בגרסאות',
+    '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' => '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_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_desc' => 'כאן תוכלו לאחזר פריטים שנמחקו או לבחור למחוק אותם מהמערכת לצמיתות. רשימה זו לא מסוננת, בשונה מרשימות פעילות דומות במערכת, בהן מוחלים מסנני הרשאות.',
+    'recycle_bin_deleted_item' => 'פריט שנמחק',
+    'recycle_bin_deleted_parent' => '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' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'נמחקו בסה"כ :count פריטים מסל המיחזור.',
+    'recycle_bin_restore_notification' => 'אוחזרו בסה"כ :count פריטים מסל המיחזור.',
+
+    // Audit Log
+    '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 Address',
+    'audit_table_date' => 'זמן הפעילות',
+    'audit_date_from' => 'טווח תאריכים החל מ...',
+    'audit_date_to' => 'טווח תאריכים עד ל...',
 
     // Role Settings
     'roles' => 'תפקידים',
@@ -96,16 +139,19 @@ return [
     'role_details' => 'פרטי תפקיד',
     'role_name' => 'שם התפקיד',
     'role_desc' => 'תיאור קצר של התפקיד',
-    'role_external_auth_id' => 'External Authentication IDs',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    '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' => 'Export content',
     'role_asset' => 'הרשאות משאבים',
+    'roles_system_warning' => 'שימו לב לכך שגישה לכל אחת משלושת ההרשאות הנ"ל יכולה לאפשר למשתמש לשנות את הפריווילגיות שלהם או של אחרים במערכת. הגדירו תפקידים להרשאות אלה למשתמשים בהם אתם בוטחים בלבד.',
     'role_asset_desc' => 'הרשאות אלו שולטות בגישת ברירת המחדל למשאבים בתוך המערכת. הרשאות של ספרים, פרקים ודפים יגברו על הרשאות אלו.',
     'role_asset_admins' => 'מנהלים מקבלים הרשאה מלאה לכל התוכן אך אפשרויות אלו עלולות להציג או להסתיר אפשרויות בממשק',
     'role_all' => 'הכל',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'פרופיל משתמש',
     'users_add_new' => 'הוסף משתמש חדש',
     'users_search' => 'חפש משתמשים',
+    'users_latest_activity' => 'פעילות אחרונה',
     'users_details' => 'פרטי משתמש',
     'users_details_desc' => 'הגדר שם לתצוגה ומייל עבור משתמש זה. כתובת המייל תשמש על מנת להתחבר למערכת',
     'users_details_desc_no_email' => 'הגדר שם לתצוגה כדי שאחרים יוכלו לזהות',
@@ -128,8 +175,8 @@ return [
     'users_role_desc' => 'בחר אילו תפקידים ישויכו למשתמש זה. אם המשתמש משוייך למספר תפקידים, ההרשאות יהיו כלל ההרשאות של כל התפקידים',
     'users_password' => 'סיסמא',
     'users_password_desc' => 'הגדר סיסמא עבור גישה למערכת. על הסיסמא להיות באורך של 5 תווים לפחות',
-    '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_send_invite_text' => 'תוכלו לבחור לשלוח למשתמש זה דוא"ל הזמנה, המאפשר להם להגדיר סיסמה משלהם. אחרת, תוכלו להגדיר את סיסמתם בעצמכם.',
+    'users_send_invite_option' => 'שלח דוא"ל הזמנה למשתמש',
     'users_external_auth_id' => 'זיהוי חיצוני - ID',
     'users_external_auth_id_desc' => 'זיהוי זה יהיה בשימוש מול מערכת ה LDAP שלך',
     'users_password_warning' => 'יש למלא רק אם ברצונך לשנות את הסיסמא.',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => 'מחק משתמש :userName',
     'users_delete_warning' => 'פעולה זו תמחק את המשתמש \':userName\' מהמערכת',
     'users_delete_confirm' => 'האם ברצונך למחוק משתמש זה?',
-    'users_delete_success' => 'המשתמש נמחק בהצלחה',
+    'users_migrate_ownership' => 'העבר בעלות',
+    'users_migrate_ownership_desc' => 'בחרו משתמש כאן במידה ואתם מעוניינים שמשתמש אחר יהפוך לבעלים של כל הפריטים שכרגע בבעלות משתמש זה.',
+    'users_none_selected' => 'לא נבחר משתמש',
+    'users_delete_success' => 'משתמש נמחק בהצלחה',
     'users_edit' => 'עריכת משתמש',
     'users_edit_profile' => 'עריכת פרופיל',
     'users_edit_success' => 'המשתמש עודכן בהצלחה',
@@ -152,32 +202,36 @@ return [
     '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' => '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',
-    '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 נמחק בהצלחה',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 2797a9c21878f4fb2a6f93f376e685547ab19b50..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'שדה :attribute חייב להיות לפחות :min תווים.',
         'array'   => 'שדה :attribute חייב להיות לפחות :min פריטים.',
     ],
-    'no_double_extension'  => 'השדה :attribute חייב להיות בעל סיומת קובץ אחת בלבד.',
     'not_in'               => 'בחירת ה-:attribute אינה תקפה.',
     'not_regex'            => 'The :attribute format is invalid.',
     'numeric'              => 'שדה :attribute חייב להיות מספר.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'שדה :attribute נחוץ כאשר :values לא בנמצא.',
     'required_without_all' => 'שדה :attribute נחוץ כאשר אף אחד מ-:values נמצאים.',
     'same'                 => 'שדה :attribute ו-:other חייבים להיות זהים.',
+    'safe_url'             => 'The provided link may not be safe.',
     'size'                 => [
         'numeric' => 'שדה :attribute חייב להיות :size.',
         'file'    => 'שדה :attribute חייב להיות :size קילובייטים.',
@@ -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..6609f55
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'stvorena stranica',
+    'page_create_notification'    => 'Stranica je uspješno stvorena',
+    'page_update'                 => 'ažurirana stranica',
+    'page_update_notification'    => 'Stranica je uspješno ažurirana',
+    'page_delete'                 => 'izbrisana stranica',
+    'page_delete_notification'    => 'Stranica je uspješno izbrisana',
+    'page_restore'                => 'obnovljena stranica',
+    'page_restore_notification'   => 'Stranica je uspješno obnovljena',
+    'page_move'                   => 'premještena stranica',
+
+    // Chapters
+    'chapter_create'              => 'stvoreno poglavlje',
+    'chapter_create_notification' => 'Poglavlje je uspješno stvoreno',
+    'chapter_update'              => 'ažurirano poglavlje',
+    'chapter_update_notification' => 'Poglavlje je uspješno ažurirano',
+    'chapter_delete'              => 'izbrisano poglavlje',
+    'chapter_delete_notification' => 'Poglavlje je uspješno izbrisano',
+    'chapter_move'                => 'premiješteno poglavlje',
+
+    // Books
+    'book_create'                 => 'stvorena knjiga',
+    'book_create_notification'    => 'Knjiga je uspješno stvorena',
+    'book_update'                 => 'ažurirana knjiga',
+    'book_update_notification'    => 'Knjiga je uspješno ažurirana',
+    'book_delete'                 => 'izbrisana knjiga',
+    'book_delete_notification'    => 'Knjiga je uspješno izbrisana',
+    'book_sort'                   => 'razvrstana knjiga',
+    'book_sort_notification'      => 'Knjiga je uspješno razvrstana',
+
+    // Bookshelves
+    'bookshelf_create'            => 'stvorena polica za knjige',
+    'bookshelf_create_notification'    => 'Polica za knjige je uspješno stvorena',
+    'bookshelf_update'                 => 'ažurirana polica za knjige',
+    'bookshelf_update_notification'    => 'Polica za knjige je uspješno ažurirana',
+    'bookshelf_delete'                 => 'izbrisana polica za knjige',
+    'bookshelf_delete_notification'    => 'Polica za knjige je uspješno izbrisana',
+
+    // 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',
+
+    // 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..2301443
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Mora imati više od 7 znakova',
+    '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' => 'Vaš e-mail adresa je potvrđena!',
+    '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' => 'Lozinka je postavljena, možete pristupiti :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.',
+];
\ No newline at end of file
diff --git a/resources/lang/hr/common.php b/resources/lang/hr/common.php
new file mode 100644 (file)
index 0000000..bc51eed
--- /dev/null
@@ -0,0 +1,95 @@
+<?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',
+
+    // 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',
+
+    // 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..4bef381
--- /dev/null
@@ -0,0 +1,324 @@
+<?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',
+
+    // 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_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_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',
+    '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.'
+];
diff --git a/resources/lang/hr/errors.php b/resources/lang/hr/errors.php
new file mode 100644 (file)
index 0000000..4562108
--- /dev/null
@@ -0,0 +1,105 @@
+<?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',
+    '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..547f27a
--- /dev/null
@@ -0,0 +1,277 @@
+<?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' => 'Postavite lozinku za prijavu u aplikaciju. Mora imati najmanje 6 znakova.',
+    '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',
+
+    //! 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',
+        '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 575e9e509e0bdefd6dad5542d03a319bbd5455bc..98bdd798bac9e66e0ca1a9a6a9fda5a5fdde40a3 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'törölte a könyvespolcot:',
     'bookshelf_delete_notification'    => 'Könyvespolc sikeresen törölve',
 
+    // 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',
+
     // Other
     'commented_on'                => 'megjegyzést fűzött hozzá:',
+    'permissions_update'          => 'updated permissions',
 ];
index a13c8300e2084722c9cd4f623c9bf08e75ed8b3d..7aaedf92ddc7daddf5aba39825d6e4c80f4c1318 100644 (file)
@@ -73,5 +73,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ő!'
+    'user_invite_success' => 'Jelszó beállítva, :appName most már elérhető!',
+
+    // 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.',
 ];
\ No newline at end of file
index 3abd3bf2761819b5807df503e0bf974ba72c4483..2e850aa2eda08b1e65a8b9ce22a6ba69f4004ffb 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Másolás',
     'reply' => 'Válasz',
     'delete' => 'Törlés',
+    'delete_confirm' => 'Törlés megerősítése',
     'search' => 'Keresés',
     'search_clear' => 'Keresés törlése',
     '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',
 
     // Sort Options
     'sort_options' => 'Rendezési beállítások',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Növekvő sorrend',
     'sort_descending' => 'Csökkenő sorrend',
     'sort_name' => 'Név',
+    'sort_default' => 'Default',
     'sort_created_at' => 'Létrehozás dátuma',
     'sort_updated_at' => 'Frissítés dátuma',
 
@@ -54,6 +61,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,17 +71,25 @@ return [
     'breadcrumb' => 'Morzsa',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profil menü',
     'view_profile' => 'Profil megtekintése',
     'edit_profile' => 'Profil szerkesztése',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Sötét mód',
+    'light_mode' => 'Világos mód',
 
     // Layout tabs
     'tab_info' => 'Információ',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Tartalom',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => 'Probléma esetén a lenti ":actionText" gombra kell kattintani, majd ki kell másolni a lenti webcímet és be kell illeszteni egy böngészőbe:',
     'email_rights' => 'Minden jog fenntartva',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
 ];
index 1f98df2dfe150dedf85fe7f35145df7782510edd..c1c57d27e76183b5139501cdb9658437d2630f32 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Több betöltése',
     'image_image_name' => 'Kép neve',
     'image_delete_used' => 'Ez a kép a lenti oldalakon van használatban.',
-    'image_delete_confirm' => 'A kép törléséhez ismét rá kell kattintani a törlésre.',
+    'image_delete_confirm_text' => 'Biztosan törölhető ez a kép?',
     'image_select_image' => 'Kép kiválasztása',
     'image_dropzone' => 'Képek feltöltése ejtéssel vagy kattintással',
     'images_deleted' => 'Képek törölve',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Kód szerkesztése',
     'code_language' => 'Kód nyelve',
     'code_content' => 'Kód tartalom',
+    'code_session_history' => 'Munkamenet előzményei',
     'code_save' => 'Kód mentése',
 ];
index 6593212f0b8c86f764428e6f1c096be07f328110..6050416e582f9bb9ad445026897bb382e6b21b77 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => ':user hozta létre :timeLength',
     'meta_updated' => 'Frissítve :timeLength',
     'meta_updated_name' => ':user frissítette :timeLength',
+    'meta_owned_name' => 'Owned by :user',
     'entity_select' => 'Entitás kiválasztása',
     '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',
@@ -33,12 +36,14 @@ 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',
     'permissions_intro' => 'Ha engedélyezett, ezek a jogosultságok elsőbbséget élveznek bármely beállított szerepkör jogosultsággal szemben.',
     'permissions_enable' => 'Egyéni jogosultságok engedélyezése',
     'permissions_save' => 'Jogosultságok mentése',
+    'permissions_owner' => 'Owner',
 
     // Search
     'search_results' => 'Keresési eredmények',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Nincsenek a keresésnek megfelelő oldalak',
     'search_for_term' => ':term keresése',
     'search_more' => 'További eredmények',
-    'search_filters' => 'Keresési szűrők',
+    'search_advanced' => 'Részletes keresés',
+    'search_terms' => 'Keresési kifejezések',
     'search_content_type' => 'Tartalomtípus',
     'search_exact_matches' => 'Pontos egyezések',
     'search_tags' => 'Címke keresések',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Jogosultságok beállítva',
     'search_created_by_me' => 'Általam létrehozott',
     'search_updated_by_me' => 'Általam frissített',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => 'Dátum beállítások',
     'search_updated_before' => 'Frissítve ez előtt',
     'search_updated_after' => 'Frissítve ez után',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Új fejezet létrehozása',
     'chapters_delete' => 'Fejezet törlése',
     'chapters_delete_named' => ':chapterName fejezet törlése',
-    'chapters_delete_explain' => '\':chapterName\' nevű fejezet törölve lesz. Minden oldal el lesz távolítva és közvetlenül a szülő könyvhöz lesz hozzáadva.',
+    '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' => 'Biztosan törölhető ez a fejezet?',
     'chapters_edit' => 'Fejezet szerkesztése',
     'chapters_edit_named' => ':chapterName fejezet szerkesztése',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Oldal változatai',
     'pages_revisions_named' => ':pageName oldal változatai',
     'pages_revision_named' => ':pageName oldal változata',
+    'pages_revision_restored_from' => 'Restored from #:id; :summary',
     'pages_revisions_created_by' => 'Létrehozta:',
     'pages_revisions_date' => 'Változat dátuma',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Fájlfeltöltés',
     'attachments_link' => 'Hivatkozás csatolása',
     'attachments_set_link' => 'Hivatkozás beállítása',
-    'attachments_delete_confirm' => 'A csatolmány törléséhez ismét rá kell kattintani a törlésre.',
+    'attachments_delete' => 'Biztosan törölhető ez a melléklet?',
     'attachments_dropzone' => 'Fájlok csatolása ejtéssel vagy kattintással',
     'attachments_no_files' => 'Nincsenek fájlok feltöltve',
     'attachments_explain_link' => 'Fájl feltöltése helyett hozzá lehet kapcsolni egy hivatkozást. Ez egy hivatkozás lesz egy másik oldalra vagy egy fájlra a felhőben.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Hivatkozás fájlra',
     'attachments_link_url_hint' => 'Weboldal vagy fájl webcíme',
     'attach' => 'Csatolás',
+    'attachments_insert_link' => 'Melléklet hivatkozás hozzáadása oldalhoz',
     'attachments_edit_file' => 'Fájl szerkesztése',
     'attachments_edit_file_name' => 'Fájl neve',
     'attachments_edit_drop_upload' => 'Feltöltés és felülírás ejtéssel vagy kattintással',
@@ -311,4 +321,4 @@ return [
     '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ő.'
-];
\ No newline at end of file
+];
index 668e57783a0a8697581dd1b4e02a144ba07191a8..a16bef5290a00c3882421bf12020282165d82319 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'A fáj feltöltése időtúllépést okozott.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Oldal eltárás csatolmány frissítése közben',
     'attachment_not_found' => 'Csatolmány nem található',
 
     // Pages
@@ -84,6 +83,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 f0c59da4bad3efd21307ea18e10a2fab273571b9..9cc3f840d433a8db7769ad7c279c624ec701c847 100644 (file)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Alkalmazás kezdőlapja',
     'app_homepage_desc' => 'A kezdőlapon az alapértelmezés szerinti nézet helyett megjelenő nézet kiválasztása. A kiválasztott oldalakon figyelmen kívül lesznek hagyva az oldal engedélyek.',
     'app_homepage_select' => 'Egy oldal kiválasztása',
+    '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' => 'Megjegyzések letiltása',
     'app_disable_comments_toggle' => 'Megjegyzések letiltása',
     'app_disable_comments_desc' => 'Megjegyzések letiltása az alkalmazás összes oldalán.<br>A már létező megjegyzések el lesznek rejtve.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Képek figyelmen kívül hagyása a változatokban',
+    '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?',
     'maint_image_cleanup_success' => ':count potenciálisan nem használt kép megtalálva és törölve!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Teszt e-mail',
     'maint_send_test_email_mail_greeting' => 'Az email kézbesítés működőképesnek tűnik!',
     'maint_send_test_email_mail_text' => 'Gratulálunk! Mivel ez az email figyelmeztetés megérkezett az email beállítások megfelelőek.',
+    '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' => 'Lomtár megnyitása',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Visszaállítás',
+    'recycle_bin_contents_empty' => 'A lomtár jelenleg üres',
+    '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_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.',
+
+    // 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' => 'Eseményszűrő',
+    'audit_event_filter_no_filter' => 'Nincs szűrő',
+    'audit_deleted_item' => 'Törölt elem',
+    'audit_deleted_item_name' => 'Név: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Szerepkörök',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Az adminisztrátorok automatikusan hozzáférést kapnak minden tartalomhoz, de ezek a beállítások megjeleníthetnek vagy elrejthetnek felhasználói felület beállításokat.',
     'role_all' => 'Összes',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Felhasználói profil',
     'users_add_new' => 'Új felhasználó hozzáadása',
     'users_search' => 'Felhasználók keresése',
+    'users_latest_activity' => 'Latest Activity',
     'users_details' => 'Felhasználó részletei',
     'users_details_desc' => 'Egy megjelenítendő név és email cím beállítása ennek a felhasználónak. Az email cím az alkalmazásba történő bejelentkezéshez lesz használva.',
     'users_details_desc_no_email' => 'Egy megjelenítendő név beállítása ennek a felhasználónak amiről mások felismerik.',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => ':userName felhasználó törlése',
     'users_delete_warning' => '\':userName\' felhasználó teljesen törölve lesz a rendszerből.',
     'users_delete_confirm' => 'Biztosan törölhető ez a felhasználó?',
-    'users_delete_success' => 'Felhasználó sikeresen eltávolítva',
+    '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' => 'Felhasználó szerkesztése',
     'users_edit_profile' => 'Profil szerkesztése',
     'users_edit_success' => 'Felhasználó sikeresen frissítve',
@@ -157,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',
@@ -164,7 +218,7 @@ return [
     'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
     'user_api_token_expiry' => 'Lejárati dátum',
     'user_api_token_expiry_desc' => 'Dátum megadása ameddig a vezérjel érvényes. Ez után a dátum után az ezzel a vezérjellel történő kérések nem fognak működni. Üresen hagyva a lejárati idő 100 évre lesz beállítva.',
-    '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_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 vezérjel sikeresen létrehozva',
     'user_api_token_update_success' => 'API vezérjel sikeresen frissítve',
     'user_api_token' => 'API vezérjel',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 845126cda95c0b6a897087fc098d8d45995885f5..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute legalább :min karakter kell legyen.',
         'array'   => ':attribute legalább :min elem kell legyen.',
     ],
-    'no_double_extension'  => ':attribute csak egy fájlkiterjesztéssel rendelkezhet.',
     'not_in'               => 'A kiválasztott :attribute érvénytelen.',
     'not_regex'            => ':attribute formátuma érvénytelen.',
     'numeric'              => ':attribute szám kell legyen.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute mező kötelező ha :values nincs beállítva.',
     'required_without_all' => ':attribute mező kötelező ha egyik :values sincs beállítva.',
     'same'                 => ':attribute és :other értékének egyeznie kell.',
+    'safe_url'             => 'The provided link may not be safe.',
     'size'                 => [
         'numeric' => ':attribute :size méretű kell legyen.',
         'file'    => ':attribute :size kilobájt méretű kell legyen.',
@@ -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.',
diff --git a/resources/lang/id/activities.php b/resources/lang/id/activities.php
new file mode 100644 (file)
index 0000000..bac965b
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'telah membuat halaman',
+    'page_create_notification'    => 'Halaman Berhasil dibuat',
+    'page_update'                 => 'halaman telah diperbaharui',
+    'page_update_notification'    => 'Berhasil mengupdate halaman',
+    'page_delete'                 => 'halaman dihapus',
+    'page_delete_notification'    => 'Berhasil menghapus halaman',
+    'page_restore'                => 'halaman telah dipulihkan',
+    'page_restore_notification'   => 'Berhasil memulihkan halaman',
+    'page_move'                   => 'halaman dipindahkan',
+
+    // Chapters
+    'chapter_create'              => 'membuat bab',
+    'chapter_create_notification' => 'Bab berhasil dibuat',
+    'chapter_update'              => 'bab diperbaharui',
+    'chapter_update_notification' => 'Bab Berhasil Dipebarui',
+    'chapter_delete'              => 'hapus bab',
+    'chapter_delete_notification' => 'Bab berhasil dihapus',
+    'chapter_move'                => 'bab dipindahkan',
+
+    // Books
+    'book_create'                 => 'membuat buku',
+    'book_create_notification'    => 'Buku berhasil dibuat',
+    'book_update'                 => 'update buku',
+    'book_update_notification'    => 'Buku Berhasil Diperbarui',
+    'book_delete'                 => 'hapus buku',
+    'book_delete_notification'    => 'Buku berhasil dihapus',
+    'book_sort'                   => 'buku yang diurutkan',
+    'book_sort_notification'      => 'Buku berhasil diurutkan',
+
+    // Bookshelves
+    'bookshelf_create'            => 'membuat rak',
+    'bookshelf_create_notification'    => 'Rak berhasil dibuat',
+    'bookshelf_update'                 => 'update rak',
+    'bookshelf_update_notification'    => 'Rak Berhasil Diperbarui',
+    'bookshelf_delete'                 => 'hapus rak buku',
+    'bookshelf_delete_notification'    => 'Rak berhasil dihapus',
+
+    // Favourites
+    'favourite_add_notification' => '":name" telah ditambahkan ke favorit Anda',
+    'favourite_remove_notification' => '":name" telah dihapus dari favorit Anda',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+
+    // Other
+    'commented_on'                => 'berkomentar pada',
+    'permissions_update'          => 'izin diperbarui',
+];
diff --git a/resources/lang/id/auth.php b/resources/lang/id/auth.php
new file mode 100644 (file)
index 0000000..d800ebb
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Kredensial tidak cocok dengan catatan kami.',
+    'throttle' => 'Terlalu banyak upaya masuk. Silahkan mencoba lagi dalam :seconds detik.',
+
+    // Login & Register
+    'sign_up' => 'Daftar',
+    'log_in' => 'Gabung',
+    'log_in_with' => 'Masuk dengan :socialDriver',
+    'sign_up_with' => 'Daftar dengan :socialDriver',
+    'logout' => 'Keluar',
+
+    'name' => 'Nama',
+    'username' => 'Nama Pengguna',
+    'email' => 'Email',
+    'password' => 'Kata Sandi',
+    'password_confirm' => 'Konfirmasi Kata Sandi',
+    'password_hint' => 'Harus lebih dari 7 karakter',
+    'forgot_password' => 'Lupa Password?',
+    'remember_me' => 'Ingat saya',
+    'ldap_email_hint' => 'Harap masukkan email yang akan digunakan untuk akun ini.',
+    'create_account' => 'Membuat Akun',
+    'already_have_account' => 'Sudah punya akun?',
+    'dont_have_account' => 'Tidak punya akun?',
+    'social_login' => 'Masuk dengan sosial media',
+    'social_registration' => 'Daftar dengan sosial media',
+    'social_registration_text' => 'Daftar dan masuk menggunakan layanan lain.',
+
+    'register_thanks' => 'Terima kasih telah mendaftar!',
+    'register_confirm' => 'Silakan periksa email Anda dan klik tombol konfirmasi untuk mengakses :appName.',
+    'registrations_disabled' => 'Pendaftaran saat ini dinonaktifkan',
+    '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.',
+    'reset_password_send_button' => 'Kirim Tautan Atur Ulang',
+    'reset_password_sent' => 'Tautan pengaturan ulang kata sandi akan dikirim ke :email jika alamat email ditemukan di sistem.',
+    'reset_password_success' => 'Kata sandi Anda telah berhasil diatur ulang.',
+    'email_reset_subject' => 'Atur ulang kata sandi :appName anda',
+    '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_resent' => 'Email konfirmasi dikirim ulang, Harap periksa kotak masuk Anda.',
+
+    'email_not_confirmed' => 'Alamat Email Tidak Dikonfirmasi',
+    'email_not_confirmed_text' => 'Alamat email Anda belum dikonfirmasi.',
+    'email_not_confirmed_click_link' => 'Silakan klik link di email yang dikirimkan segera setelah Anda mendaftar.',
+    'email_not_confirmed_resend' => 'Jika Anda tidak dapat menemukan email tersebut, Anda dapat mengirim ulang email konfirmasi dengan mengirimkan formulir di bawah ini.',
+    'email_not_confirmed_resend_button' => 'Mengirimkan kembali email konfirmasi',
+
+    // User Invite
+    'user_invite_email_subject' => 'Anda telah diundang untuk bergabung di :appName!',
+    'user_invite_email_greeting' => 'Sebuah akun telah dibuat untuk Anda di :appName.',
+    'user_invite_email_text' => 'Klik tombol di bawah untuk mengatur kata sandi akun dan mendapatkan akses:',
+    'user_invite_email_action' => 'Atur Kata Sandi Akun',
+    '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!',
+
+    // 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.',
+];
\ No newline at end of file
diff --git a/resources/lang/id/common.php b/resources/lang/id/common.php
new file mode 100644 (file)
index 0000000..c9aac2e
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Batal',
+    'confirm' => 'Konfirmasi',
+    'back' => 'Kembali',
+    'save' => 'Simpan',
+    'continue' => 'Lanjutkan',
+    'select' => 'Pilih',
+    'toggle_all' => 'Alihkan Semua',
+    'more' => 'Lebih banyak',
+
+    // Form Labels
+    'name' => 'Nama',
+    'description' => 'Deskripsi',
+    'role' => 'Peran',
+    'cover_image' => 'Sampul gambar',
+    'cover_image_description' => 'Gambar ini harus berukuran kira-kira 440x250 piksel.',
+    
+    // Actions
+    'actions' => 'Tindakan',
+    'view' => 'Lihat',
+    'view_all' => 'Lihat Semua',
+    'create' => 'Buat',
+    'update' => 'Perbarui',
+    'edit' => 'Sunting',
+    'sort' => 'Sortir',
+    'move' => 'Pindahkan',
+    'copy' => 'Salin',
+    'reply' => 'Balas',
+    'delete' => 'Hapus',
+    'delete_confirm' => 'Konfirmasi Penghapusan',
+    'search' => 'Cari',
+    'search_clear' => 'Hapus Pencarian',
+    'reset' => 'Atur ulang',
+    'remove' => 'Hapus',
+    'add' => 'Tambah',
+    'configure' => 'Configure',
+    'fullscreen' => 'Layar Penuh',
+    'favourite' => 'Favorit',
+    'unfavourite' => 'Batal favorit',
+    'next' => 'Selanjutnya',
+    'previous' => 'Sebelumnya',
+
+    // Sort Options
+    '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 Diperbarui',
+
+    // Misc
+    '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',
+    'skip_to_main_content' => 'Lewatkan ke konten utama',
+    'toggle_details' => 'Rincian Alihan',
+    'toggle_thumbnails' => 'Alihkan Gambar Mini',
+    'details' => 'Rincian',
+    'grid_view' => 'Tampilan Bergaris',
+    'list_view' => 'Tampilan Daftar',
+    'default' => 'Bawaan',
+    'breadcrumb' => 'Breadcrumb',
+
+    // Header
+    '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 Terang',
+
+    // Layout tabs
+    'tab_info' => 'Informasi',
+    'tab_info_label' => 'Tab: Tampilkan Informasi Sekunder',
+    'tab_content' => 'Konten',
+    '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 ke dalam peramban web Anda:',
+    'email_rights' => 'Hak cipta dilindungi',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Kebijakan Privasi',
+    'terms_of_service' => 'Ketentuan Layanan',
+];
diff --git a/resources/lang/id/components.php b/resources/lang/id/components.php
new file mode 100644 (file)
index 0000000..309e931
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Pilih Gambar',
+    'image_all' => 'Semua',
+    'image_all_title' => 'Lihat semua gambar',
+    'image_book_title' => 'Lihat gambar yang diunggah ke buku ini',
+    'image_page_title' => 'Lihat gambar yang diunggah ke halaman ini',
+    'image_search_hint' => 'Cari berdasarkan nama gambar',
+    'image_uploaded' => 'Diunggah :uploadedDate',
+    'image_load_more' => 'Muat lebih banyak',
+    'image_image_name' => 'Muat lebih banyak',
+    'image_delete_used' => 'Gambar ini digunakan untuk halaman dibawah ini.',
+    'image_delete_confirm_text' => 'Anda yakin ingin menghapus gambar ini?',
+    'image_select_image' => 'Pilih gambar',
+    'image_dropzone' => 'Lepaskan gambar atau klik di sini untuk mengunggah',
+    'images_deleted' => 'Gambar Dihapus',
+    'image_preview' => 'Pratinjau Gambar',
+    'image_upload_success' => 'Gambar berhasil diunggah',
+    'image_update_success' => 'Detail gambar berhasil diperbarui',
+    'image_delete_success' => 'Gambar berhasil dihapus',
+    'image_upload_remove' => 'Menghapus',
+
+    // Code Editor
+    'code_editor' => 'Edit Kode',
+    'code_language' => 'Bahasa Kode',
+    'code_content' => 'Konten Kode',
+    'code_session_history' => 'Konten Kode',
+    'code_save' => 'Simpan Kode',
+];
diff --git a/resources/lang/id/entities.php b/resources/lang/id/entities.php
new file mode 100644 (file)
index 0000000..aa92fd0
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Baru saja dibuat',
+    'recently_created_pages' => 'Halaman baru saja dibuat',
+    'recently_updated_pages' => 'Halaman baru saja diperbaharui',
+    'recently_created_chapters' => 'Bab baru saja dibuat',
+    'recently_created_books' => 'Buku baru saja dibuat',
+    'recently_created_shelves' => 'Rak baru saja dibuat',
+    'recently_update' => 'Baru saja diperbaharui',
+    'recently_viewed' => 'Baru saja dilihat',
+    'recent_activity' => 'Aktivitas Terbaru',
+    'create_now' => 'Buat Sekarang',
+    'revisions' => 'Revisi',
+    'meta_revision' => 'Revisi #:revisionCount',
+    'meta_created' => 'Dibuat :timeLength',
+    'meta_created_name' => 'Dibuat :timeLength oleh :user',
+    'meta_updated' => 'Diperbaharui :timeLength',
+    'meta_updated_name' => 'Diperbaharui :timeLength oleh :user',
+    'meta_owned_name' => 'Dimiliki oleh :user',
+    'entity_select' => 'Pilihan Entitas',
+    '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',
+    'export' => 'Ekspor',
+    'export_html' => 'File Web Berisi',
+    'export_pdf' => 'Dokumen PDF',
+    'export_text' => 'Dokumen Teks Biasa',
+    'export_md' => 'File Markdown',
+
+    // Permissions and restrictions
+    'permissions' => 'Izin',
+    'permissions_intro' => 'Setelah diaktifkan, izin ini akan menjadi prioritas di atas izin peran yang ditetapkan.',
+    'permissions_enable' => 'Aktifkan Izin Kustom',
+    'permissions_save' => 'Simpan Izin',
+    'permissions_owner' => 'Pemilik',
+
+    // Search
+    'search_results' => 'Hasil Pencarian',
+    'search_total_results_found' => ':count hasil hitung ditemukan |:count hasil hitung total tang di temukan',
+    'search_clear' => 'Bersihkan pencarian',
+    'search_no_pages' => 'Tidak ada halaman yang cocok dengan pencarian ini',
+    'search_for_term' => 'Pencarian untuk :term',
+    'search_more' => 'Hasil lebih',
+    'search_advanced' => 'Pencarian Lanjutan',
+    'search_terms' => 'Cari Istilah',
+    'search_content_type' => 'Tipe Konten',
+    'search_exact_matches' => 'Pertandingan Persis',
+    'search_tags' => 'Pencarian Tag',
+    'search_options' => 'Pilihan',
+    'search_viewed_by_me' => 'Dilihat oleh saya',
+    'search_not_viewed_by_me' => 'Tidak dilihat oleh saya',
+    'search_permissions_set' => 'Izin ditetapkan',
+    'search_created_by_me' => 'Dibuat oleh saya',
+    'search_updated_by_me' => 'Diperbaharui oleh saya',
+    'search_owned_by_me' => 'Milik Saya',
+    'search_date_options' => 'Opsi Tanggal',
+    'search_updated_before' => 'Diperbaharui sebelum',
+    'search_updated_after' => 'Diperbaharui setelah',
+    'search_created_before' => 'Dibuat sebelum',
+    'search_created_after' => 'Dibuat setelah',
+    'search_set_date' => 'Atur Tanggal',
+    'search_update' => 'Perbaharui pencarian',
+
+    // Shelves
+    'shelf' => 'Rak',
+    'shelves' => 'Rak',
+    'x_shelves' => ':count Rak|:count Rak',
+    'shelves_long' => 'Rak Buku',
+    'shelves_empty' => 'Tidak ada rak yang dibuat',
+    'shelves_create' => 'Buat Rak baru',
+    'shelves_popular' => 'Rak Terpopuler',
+    'shelves_new' => 'Rak Baru',
+    'shelves_new_action' => 'Rak Baru',
+    'shelves_popular_empty' => 'Rak paling populer akan muncul di sini.',
+    'shelves_new_empty' => 'Rak yang paling baru dibuat akan muncul di sini.',
+    'shelves_save' => 'Simpan Rak',
+    'shelves_books' => 'Buku di rak ini',
+    'shelves_add_books' => 'Tambahkan buku ke rak ini',
+    'shelves_drag_books' => 'Tarik buku ke sini untuk menambahkannya ke rak ini',
+    'shelves_empty_contents' => 'Rak ini tidak memiliki buku yang ditugaskan padanya',
+    'shelves_edit_and_assign' => 'Edit rak untuk menetapkan buku',
+    'shelves_edit_named' => 'Edit Rak Buku :name',
+    'shelves_edit' => 'Edit Rak Buku',
+    'shelves_delete' => 'Hapus rak buku',
+    'shelves_delete_named' => 'Hapus rak buku :name',
+    'shelves_delete_explain' => "Ini akan menghapus rak buku dengan nama ':name'. Buku yang berisi tidak akan dihapus.",
+    'shelves_delete_confirmation' => 'Apakah Anda yakin ingin menghapus rak buku ini?',
+    '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.',
+    'shelves_copy_permission_success' => 'Izin rak buku disalin ke :count buku',
+
+    // Books
+    'book' => 'Buku',
+    'books' => 'Semua Buku',
+    'x_books' => ':count Buku|:count Semua Buku',
+    'books_empty' => 'Tidak ada buku yang telah dibuat',
+    'books_popular' => 'Buku Populer',
+    'books_recent' => 'Buku Terbaru',
+    'books_new' => 'Buku baru',
+    'books_new_action' => 'Buku baru',
+    'books_popular_empty' => 'Buku paling populer akan muncul di sini.',
+    'books_new_empty' => 'The most recently created books will appear here.',
+    'books_create' => 'Buat Buku Baru',
+    'books_delete' => 'Hapus Buku',
+    'books_delete_named' => 'Hapus buku :bookName',
+    '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' => 'Sunting Buku :bookName',
+    'books_form_book_name' => 'Nama Buku',
+    'books_save' => 'Simpan Buku',
+    'books_permissions' => 'Izin Buku',
+    'books_permissions_updated' => 'Izin Buku Diperbarui',
+    'books_empty_contents' => 'Tidak ada halaman atau bab yang telah dibuat untuk buku ini.',
+    'books_empty_create_page' => 'Buat halaman baru',
+    'books_empty_sort_current_book' => 'Sortir buku saat ini',
+    'books_empty_add_chapter' => 'Tambahkan satu bab',
+    'books_permissions_active' => 'Izin Buku Aktif',
+    'books_search_this' => 'Cari buku ini',
+    'books_navigation' => 'Navigasi Buku',
+    'books_sort' => 'Sortir Isi Buku',
+    'books_sort_named' => 'Sortir Buku :bookName',
+    'books_sort_name' => 'Diurutkan berdasarkan nama',
+    'books_sort_created' => 'Urutkan berdasarkan Tanggal Dibuat',
+    'books_sort_updated' => 'Urutkan berdasarkan Tanggal Diperbarui',
+    'books_sort_chapters_first' => 'Bab Pertama',
+    'books_sort_chapters_last' => 'Bab Terakhir',
+    'books_sort_show_other' => 'Tunjukkan Buku Lain',
+    'books_sort_save' => 'Simpan Pesanan Baru',
+
+    // Chapters
+    'chapter' => 'Bab',
+    'chapters' => 'Bab',
+    'x_chapters' => ':count Bab |:count Bab',
+    'chapters_popular' => 'Bab Populer',
+    'chapters_new' => 'Bab Baru',
+    'chapters_create' => 'Buat Bab Baru',
+    'chapters_delete' => 'Hapur Bab',
+    'chapters_delete_named' => 'Hapus bab dengan nama :chapterName',
+    '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' => '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_permissions' => 'Izin Bab',
+    'chapters_empty' => 'Saat ini tidak ada halaman dalam bab ini.',
+    'chapters_permissions_active' => 'Izin Bab Aktif',
+    'chapters_permissions_success' => 'Izin Bab Diperbarui',
+    'chapters_search_this' => 'Cari bab ini',
+
+    // Pages
+    'page' => 'Halaman',
+    'pages' => 'Semua Halaman',
+    'x_pages' => ':count Halaman|:count Semua Halaman',
+    'pages_popular' => 'Halaman Populer',
+    'pages_new' => 'Lembaran baru',
+    'pages_attachments' => 'Lampiran',
+    'pages_navigation' => 'Halaman Navigasi',
+    'pages_delete' => 'Hapus Halaman',
+    'pages_delete_named' => 'Hapus Halaman :pageName',
+    'pages_delete_draft_named' => 'Hapus Halaman Draf :pageName',
+    'pages_delete_draft' => 'Hapus Halaman Draf',
+    'pages_delete_success' => 'Halaman dihapus',
+    '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' => 'Menyunting Halaman :pageName',
+    'pages_edit_draft_options' => 'Opsi Draf',
+    'pages_edit_save_draft' => 'Simpan Draf',
+    'pages_edit_draft' => 'Edit Halaman Draf',
+    'pages_editing_draft' => 'Mengedit Draf',
+    'pages_editing_page' => 'Mengedit Draf',
+    'pages_edit_draft_save_at' => 'Draf disimpan pada ',
+    'pages_edit_delete_draft' => 'Hapus Draf',
+    'pages_edit_discard_draft' => 'Buang Draf',
+    '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',
+    'pages_title' => 'Judul Halaman',
+    'pages_name' => 'Nama Halaman',
+    'pages_md_editor' => 'Editor',
+    'pages_md_preview' => 'Pratinjau',
+    'pages_md_insert_image' => 'Sisipkan Gambar',
+    'pages_md_insert_link' => 'Sisipkan Tautan Entitas',
+    'pages_md_insert_drawing' => 'Sisipkan Gambar',
+    'pages_not_in_chapter' => 'Halaman tidak dalam satu bab',
+    'pages_move' => 'Pindahkan Halaman',
+    'pages_move_success' => 'Halaman dipindahkan ke ":parentName"',
+    'pages_copy' => 'Salin Halaman',
+    'pages_copy_desination' => 'Salin Tujuan',
+    'pages_copy_success' => 'Halaman berhasil disalin',
+    'pages_permissions' => 'Izin Halaman',
+    'pages_permissions_success' => 'Izin halaman diperbarui',
+    'pages_revision' => 'Revisi',
+    'pages_revisions' => 'Revisi Halaman',
+    'pages_revisions_named' => 'Revisi Halaman untuk :pageName',
+    'pages_revision_named' => 'Revisi Halaman untuk :pageName',
+    'pages_revision_restored_from' => 'Dipulihkan dari #:id; :summary',
+    'pages_revisions_created_by' => 'Dibuat Oleh',
+    'pages_revisions_date' => 'Tanggal Revisi',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Revisi #:id',
+    'pages_revisions_numbered_changes' => 'Revisi #:id Berubah',
+    'pages_revisions_changelog' => 'Changelog',
+    'pages_revisions_changes' => 'Perubahan',
+    'pages_revisions_current' => 'Versi sekarang',
+    'pages_revisions_preview' => 'Pratinjau',
+    'pages_revisions_restore' => 'Mengembalikan',
+    'pages_revisions_none' => 'Halaman ini tidak memiliki revisi',
+    'pages_copy_link' => 'Salin tautan',
+    '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 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_edit_active' => [
+        'start_a' => ':count pengguna sudah mulai mengedit halaman ini',
+        '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' => 'Konsep dibuang, Penyunting telah diperbarui dengan konten halaman saat ini',
+    'pages_specific' => 'Halaman Tertentu',
+    'pages_is_template' => 'Template Halaman',
+
+    // Editor Sidebar
+    'page_tags' => 'Halaman Tag',
+    'chapter_tags' => 'Bab Tag',
+    'book_tags' => 'Tag Buku',
+    'shelf_tags' => 'Tag Rak',
+    'tag' => 'Tag',
+    'tags' =>  'Semua Tag',
+    'tag_name' =>  'Nama Tag',
+    'tag_value' => 'Nilai Tag (opsional)',
+    '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',
+    'attachments' => 'Lampiran',
+    '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 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 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' => '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' => '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' => 'Berkas berhasil diunggah',
+    'attachments_file_updated' => 'File berhasil diperbarui',
+    'attachments_link_attached' => 'Tautan berhasil dilampirkan ke halaman',
+    'templates' => 'Template',
+    'templates_set_as_template' => 'Halaman adalah template',
+    'templates_explain_set_as_template' => 'Anda dapat mengatur halaman ini sebagai template sehingga isinya dapat digunakan saat membuat halaman lain. Pengguna lain akan dapat menggunakan template ini jika mereka memiliki izin melihat halaman ini.',
+    'templates_replace_content' => 'Ganti Halaman Konten',
+    'templates_append_content' => 'Tambahkan ke halaman konten',
+    'templates_prepend_content' => 'Tambahkan ke halaman konten',
+
+    // Profile View
+    'profile_user_for_x' => 'Pengguna untuk :time',
+    'profile_created_content' => 'Konten yang Dibuat',
+    'profile_not_created_pages' => ':userName belum membuat halaman apa pun',
+    'profile_not_created_chapters' => ':userName belum membuat bab apa pun',
+    'profile_not_created_books' => ':userName belum membuat buku apa pun',
+    'profile_not_created_shelves' => ':userName belum membuat rak apa pun',
+
+    // Comments
+    'comment' => 'Komentar',
+    'comments' => 'Komentar',
+    'comment_add' => 'Tambah Komentar',
+    'comment_placeholder' => 'Tinggalkan komentar disini',
+    'comment_count' => '{0} Tidak ada komentar |{1} 1 Komentar |[2,*] :count Komentar',
+    'comment_save' => 'Simpan Komentar',
+    'comment_saving' => 'Menyimpan Komentar...',
+    'comment_deleting' => 'Menghapus Komentar...',
+    'comment_new' => 'Komentar Baru',
+    'comment_created' => 'dikomentari oleh :createDiff',
+    'comment_updated' => 'Diperbarui :updateDiff oleh :username',
+    'comment_deleted_success' => 'Komentar telah dihapus',
+    'comment_created_success' => 'Komentar telah di tambahkan',
+    'comment_updated_success' => 'Komentar Telah diperbaharui',
+    'comment_delete_confirm' => 'Anda yakin ingin menghapus komentar ini?',
+    'comment_in_reply_to' => 'Sebagai balasan untuk :commentId',
+
+    // Revision
+    '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.'
+];
diff --git a/resources/lang/id/errors.php b/resources/lang/id/errors.php
new file mode 100644 (file)
index 0000000..9244c96
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Anda tidak memiliki izin untuk mengakses halaman yang diminta.',
+    'permissionJson' => 'Anda tidak memiliki izin untuk melakukan tindakan yang diminta.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Pengguna dengan email :email sudah ada tetapi dengan kredensial berbeda.',
+    'email_already_confirmed' => 'Email telah dikonfirmasi, Coba masuk.',
+    'email_confirmation_invalid' => 'Token konfirmasi ini tidak valid atau telah digunakan, Silakan coba mendaftar lagi.',
+    '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 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 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',
+    'social_no_action_defined' => 'Tidak ada tindakan yang ditentukan',
+    '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 :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 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 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' => '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 sebuah halaman saat diatur sebagai sebuah halaman beranda',
+
+    // Entities
+    'entity_not_found' => 'Entitas tidak ditemukan',
+    'bookshelf_not_found' => 'Rak buku tidak ditemukan',
+    'book_not_found' => 'Buku tidak ditemukan',
+    'page_not_found' => 'Halaman tidak ditemukan',
+    'chapter_not_found' => 'Bab tidak ditemukan',
+    'selected_book_not_found' => 'Buku yang dipilih tidak ditemukan',
+    'selected_book_chapter_not_found' => 'Buku atau Bab yang dipilih tidak ditemukan',
+    'guests_cannot_save_drafts' => 'Tamu tidak dapat menyimpan Draf',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Anda tidak dapat menghapus satu-satunya admin',
+    'users_cannot_delete_guest' => 'Anda tidak dapat menghapus pengguna tamu',
+
+    // Roles
+    '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.',
+
+    // Comments
+    'comment_list' => 'Terjadi kesalahan saat mengambil komentar.',
+    'cannot_add_comment_to_draft' => 'Anda tidak dapat menambahkan komentar ke draf.',
+    'comment_add' => 'Terjadi kesalahan saat menambahkan / memperbarui komentar.',
+    'comment_delete' => 'Terjadi kesalahan saat menghapus komentar.',
+    'empty_comment' => 'Tidak dapat menambahkan komentar kosong.',
+
+    // Error pages
+    '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',
+    'back_soon' => 'Ini akan segera kembali.',
+
+    // API errors
+    'api_no_authorization_found' => 'Tidak ada token otorisasi yang ditemukan pada permintaan tersebut',
+    'api_bad_authorization_format' => 'Token otorisasi ditemukan pada permintaan tetapi formatnya salah',
+    'api_user_token_not_found' => 'Tidak ditemukan token API yang cocok untuk token otorisasi yang diberikan',
+    'api_incorrect_token_secret' => 'Rahasia yang diberikan untuk token API bekas yang diberikan salah',
+    'api_user_no_api_permission' => 'Pemilik token API yang digunakan tidak memiliki izin untuk melakukan panggilan API',
+    'api_user_token_expired' => 'Token otorisasi yang digunakan telah kedaluwarsa',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Kesalahan dilempar saat mengirim email uji:',
+
+];
diff --git a/resources/lang/id/pagination.php b/resources/lang/id/pagination.php
new file mode 100644 (file)
index 0000000..4898c74
--- /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; Sebelumnya',
+    'next'     => 'Lanjut &raquo;',
+
+];
diff --git a/resources/lang/id/passwords.php b/resources/lang/id/passwords.php
new file mode 100644 (file)
index 0000000..3ee2e4d
--- /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' => 'Kata sandi harus setidaknya delapan karakter dan sesuai dengan konfirmasi.',
+    'user' => "Kami tidak dapat menemukan pengguna dengan alamat email tersebut.",
+    'token' => 'Token setel ulang sandi tidak valid untuk alamat email ini.',
+    'sent' => 'Kami telah mengirimkan email tautan pengaturan ulang kata sandi Anda!',
+    'reset' => 'Kata sandi Anda telah disetel ulang!',
+
+];
diff --git a/resources/lang/id/settings.php b/resources/lang/id/settings.php
new file mode 100644 (file)
index 0000000..c01cbdb
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Pengaturan',
+    'settings_save' => 'Simpan Pengaturan',
+    'settings_save_success' => 'Pengaturan disimpan',
+
+    // App Settings
+    'app_customization' => 'Kustomisasi',
+    'app_features_security' => 'Fitur & Keamanan',
+    'app_name' => 'Nama aplikasi',
+    'app_name_desc' => 'Nama ini ditampilkan di tajuk dan di semua email yang dikirim oleh sistem.',
+    'app_name_header' => 'Tampilkan nama di header',
+    'app_public_access' => 'Akses publik',
+    'app_public_access_desc' => 'Mengaktifkan opsi ini akan memungkinkan pengunjung, yang tidak masuk, untuk mengakses konten dalam contoh BookStack Anda.',
+    'app_public_access_desc_guest' => 'Akses untuk pengunjung umum dapat dikontrol melalui pengguna "Tamu".',
+    'app_public_access_toggle' => 'Izinkan akses publik',
+    'app_public_viewing' => 'Izinkan tontonan publik?',
+    'app_secure_images' => 'Unggahan Gambar Keamanan Lebih Tinggi',
+    'app_secure_images_toggle' => 'Aktifkan unggahan gambar dengan keamanan lebih tinggi',
+    'app_secure_images_desc' => 'Untuk alasan performa, semua gambar bersifat publik. Opsi ini menambahkan string acak yang sulit ditebak di depan url gambar. Pastikan indeks direktori tidak diaktifkan untuk mencegah akses mudah.',
+    'app_editor' => 'Halaman Editor',
+    'app_editor_desc' => 'Pilih editor mana yang akan digunakan oleh semua pengguna untuk mengedit halaman.',
+    'app_custom_html' => 'Kustom Konten HTML Head',
+    'app_custom_html_desc' => 'Konten apa pun yang ditambahkan di sini akan dimasukkan ke bagian bawah <head> bagian dari setiap halaman. Ini berguna untuk mengganti gaya atau menambahkan kode analitik.',
+    'app_custom_html_disabled_notice' => 'Kustom konten HTML Head dinonaktifkan pada halaman pengaturan ini untuk memastikan setiap perubahan yang mengganggu dapat dikembalikan.',
+    'app_logo' => 'Logo Aplikasi',
+    'app_logo_desc' => 'Gambar ini seharusnya memiliki ketinggian 43px Gambar besar akan diperkecil.',
+    'app_primary_color' => 'Warna Utama Aplikasi',
+    'app_primary_color_desc' => 'Menyetel warna utama untuk aplikasi termasuk spanduk, tombol, dan tautan.',
+    'app_homepage' => 'Beranda Aplikasi',
+    'app_homepage_desc' => 'Pilih tampilan untuk ditampilkan di beranda alih-alih tampilan default. Izin halaman diabaikan untuk halaman yang dipilih.',
+    'app_homepage_select' => 'Pilih halaman',
+    'app_footer_links' => 'Link Footer',
+    'app_footer_links_desc' => 'Tambahkan link untuk ditampilkan dalam footer situs. Ini akan ditampilkan di bagian bawah kebanyakan halaman, termasuk yang tidak memerlukan login. Anda dapat menggunakan label "trans::<key>" untuk menggunakan terjemahan yang ditentukan sistem. Sebagai contoh: Menggunakan "trans::common.privacy_policy" akan memberikan teks terjemahan "Privacy Policy" dan akan memberikan teks "Terms of Service".terjemahan trans::common.terms_of_service".',
+    'app_footer_links_label' => 'Link Label',
+    'app_footer_links_url' => 'Link URL',
+    'app_footer_links_add' => 'Tambahkan Link Footer',
+    'app_disable_comments' => 'Nonaktifkan Komentar',
+    'app_disable_comments_toggle' => 'Nonaktifkan komentar',
+    'app_disable_comments_desc' => 'Menonaktifkan komentar di semua halaman dalam aplikasi. <br> Komentar yang ada tidak ditampilkan.',
+
+    // Color settings
+    'content_colors' => 'Warna Konten',
+    'content_colors_desc' => 'Menyetel warna untuk semua elemen dalam hierarki organisasi halaman. Disarankan memilih warna dengan kecerahan yang mirip dengan warna default agar mudah dibaca.',
+    'bookshelf_color' => 'Warna Rak',
+    'book_color' => 'Warna Buku',
+    'chapter_color' => 'Warna Bab',
+    'page_color' => 'Warna Halaman',
+    'page_draft_color' => 'Warna Halaman Draf',
+
+    // Registration Settings
+    'reg_settings' => 'Pendaftaran',
+    'reg_enable' => 'Aktifkan Pendaftaran',
+    'reg_enable_toggle' => 'Aktifkan Pendaftaran',
+    'reg_enable_desc' => 'Saat pendaftaran diaktifkan, pengguna akan dapat mendaftar sendiri sebagai pengguna aplikasi. Setelah pendaftaran, mereka diberi peran pengguna default tunggal.',
+    'reg_default_role' => 'Peran pengguna default setelah pendaftaran',
+    'reg_enable_external_warning' => 'Opsi di atas diabaikan saat otentikasi LDAP atau SAML eksternal aktif. Akun pengguna untuk anggota yang tidak ada akan dibuat secara otomatis jika otentikasi, terhadap sistem eksternal yang digunakan, berhasil.',
+    'reg_email_confirmation' => 'Konfirmasi email',
+    'reg_email_confirmation_toggle' => 'Memerlukan konfirmasi email',
+    'reg_confirm_email_desc' => 'Jika batasan domain digunakan maka konfirmasi email akan diperlukan dan opsi ini akan diabaikan.',
+    'reg_confirm_restrict_domain' => 'Pembatasan Domain',
+    'reg_confirm_restrict_domain_desc' => 'Masukkan daftar domain email yang dipisahkan dengan koma yang ingin Anda batasi pendaftarannya. Pengguna akan dikirimi email untuk mengonfirmasi alamat mereka sebelum diizinkan untuk berinteraksi dengan aplikasi. <br> Perhatikan bahwa pengguna akan dapat mengubah alamat email mereka setelah pendaftaran berhasil.',
+    'reg_confirm_restrict_domain_placeholder' => 'Tidak ada batasan yang ditetapkan',
+
+    // 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_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?',
+    'maint_image_cleanup_success' => ':count gambar yang mungkin tidak digunakan ditemukan dan dihapus!',
+    'maint_image_cleanup_nothing_found' => 'Tidak ada gambar yang tidak digunakan ditemukan, Tidak ada yang dihapus!',
+    'maint_send_test_email' => 'Kirim Email Tes',
+    'maint_send_test_email_desc' => 'Ini mengirimkan email percobaan ke alamat email Anda yang ditentukan di profil Anda.',
+    'maint_send_test_email_run' => 'Kirim email tes',
+    'maint_send_test_email_success' => 'Email dikirim ke :address',
+    'maint_send_test_email_mail_subject' => 'Uji Email',
+    'maint_send_test_email_mail_greeting' => 'Pengiriman email sepertinya berhasil!',
+    'maint_send_test_email_mail_text' => 'Selamat! Saat Anda menerima pemberitahuan email ini, pengaturan email Anda tampaknya telah dikonfigurasi dengan benar.',
+    'maint_recycle_bin_desc' => 'Rak, buku, bab & halaman yang dihapus dikirim ke recycle bin sehingga dapat dipulihkan atau dihapus secara permanen. Item lama di recycle bin dapat dihapus secara otomatis setelah beberapa saat tergantung pada konfigurasi sistem.',
+    'maint_recycle_bin_open' => 'Buka Tempat Sampah',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Mengembalikan',
+    'recycle_bin_contents_empty' => 'Hapus Secara Permanen',
+    'recycle_bin_empty' => 'Kosongkan Tempat Sampah',
+    'recycle_bin_empty_confirm' => 'Ini akan menghancurkan secara permanen semua item di tempat sampah termasuk konten yang ada di dalam setiap item. Anda yakin ingin mengosongkan tempat sampah?',
+    'recycle_bin_destroy_confirm' => 'Tindakan ini akan menghapus item ini secara permanen, bersama dengan elemen turunan apa pun yang tercantum di bawah, dari sistem dan Anda tidak akan dapat memulihkan konten ini. Anda yakin ingin menghapus item ini secara permanen?',
+    'recycle_bin_destroy_list' => 'Item yang akan Dihancurkan',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Log Audit',
+    'audit_desc' => 'Log audit ini menampilkan daftar aktivitas yang dilacak dalam sistem. Daftar ini tidak difilter, tidak seperti daftar aktivitas serupa di sistem tempat filter izin diterapkan.',
+    'audit_event_filter' => 'Filter Peristiwa',
+    'audit_event_filter_no_filter' => 'Tanpa Filter',
+    'audit_deleted_item' => 'Item yang Dihapus',
+    'audit_deleted_item_name' => 'Nama :name',
+    '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',
+
+    // Role Settings
+    'roles' => 'Peran',
+    'role_user_roles' => 'Peran Pengguna',
+    'role_create' => 'Buat Peran Baru',
+    'role_create_success' => 'Peran berhasil dibuat',
+    'role_delete' => 'Hapus Peran',
+    'role_delete_confirm' => 'Ini akan menghapus peran dengan nama \':roleName\'.',
+    'role_delete_users_assigned' => 'Peran ini memiliki :userCount pengguna yang ditugaskan padanya. Jika Anda ingin memindahkan pengguna dari peran ini pilih peran baru di bawah.',
+    'role_delete_no_migration' => "Jangan migrasikan pengguna",
+    'role_delete_sure' => 'Anda yakin ingin menghapus peran ini?',
+    'role_delete_success' => 'Peran berhasil dihapus',
+    'role_edit' => 'Edit Peran',
+    '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',
+    'role_manage_roles' => 'Kelola peran & izin peran',
+    'role_manage_entity_permissions' => 'Kelola semua izin buku, bab & halaman',
+    'role_manage_own_entity_permissions' => 'Kelola izin di buku, bab & halaman sendiri',
+    '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.',
+    'role_asset_admins' => 'Admin secara otomatis diberi akses ke semua konten tetapi opsi ini dapat menampilkan atau menyembunyikan opsi UI.',
+    'role_all' => 'Semua',
+    'role_own' => 'Sendiri',
+    'role_controlled_by_asset' => 'Dikendalikan oleh aset tempat mereka diunggah',
+    'role_save' => 'Simpan Peran',
+    'role_update_success' => 'Peran berhasil diperbarui',
+    'role_users' => 'Peran berhasil diperbarui',
+    'role_users_none' => 'Saat ini tidak ada pengguna yang ditugaskan untuk peran ini',
+
+    // Users
+    'users' => 'Pengguna',
+    'user_profile' => 'Profil Pengguna',
+    'users_add_new' => 'Tambahkan pengguna baru',
+    'users_search' => 'Cari Pengguna',
+    'users_latest_activity' => 'Aktivitas Terbaru',
+    'users_details' => 'Detail Pengguna',
+    'users_details_desc' => 'Tetapkan nama tampilan dan alamat email untuk pengguna ini. Alamat email akan digunakan untuk masuk ke aplikasi.',
+    'users_details_desc_no_email' => 'Tetapkan nama tampilan untuk pengguna ini agar orang lain dapat mengenalinya.',
+    '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_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',
+    'users_external_auth_id_desc' => 'Ini adalah ID yang digunakan untuk mencocokkan pengguna ini saat berkomunikasi dengan sistem otentikasi eksternal Anda.',
+    'users_password_warning' => 'Hanya isi di bawah ini jika Anda ingin mengubah kata sandi Anda.',
+    'users_system_public' => 'Pengguna ini mewakili semua pengguna tamu yang mengunjungi instance Anda. Ini tidak dapat digunakan untuk masuk tetapi ditetapkan secara otomatis.',
+    'users_delete' => 'Hapus pengguna',
+    'users_delete_named' => 'Hapus Pengguna :userName',
+    'users_delete_warning' => 'Ini sepenuhnya akan menghapus pengguna ini dengan nama \':userName\' dari sistem.',
+    'users_delete_confirm' => 'Apakah Anda yakin ingin menghapus pengguna ini?',
+    'users_migrate_ownership' => 'Migrasikan Kepemilikan',
+    'users_migrate_ownership_desc' => 'Pilih pengguna di sini jika Anda ingin pengguna lain menjadi pemilik semua item yang saat ini dimiliki oleh pengguna ini.',
+    'users_none_selected' => 'Tidak ada pengguna yang dipilih',
+    'users_delete_success' => 'Pengguna berhasil dihapus',
+    'users_edit' => 'Edit Pengguna',
+    'users_edit_profile' => 'Edit Profil',
+    'users_edit_success' => 'Pengguna berhasil diperbarui',
+    'users_avatar' => 'Abatar Pengguna',
+    'users_avatar_desc' => 'Pilih gambar untuk mewakili pengguna ini. berukuran 256px.',
+    'users_preferred_language' => 'Bahasa Pilihan',
+    'users_preferred_language_desc' => 'Opsi ini akan mengubah bahasa yang digunakan untuk antarmuka pengguna aplikasi. Ini tidak akan memengaruhi konten yang dibuat pengguna.',
+    'users_social_accounts' => 'Akun Sosial',
+    'users_social_accounts_info' => 'Di sini Anda dapat menghubungkan akun Anda yang lain untuk login yang lebih cepat dan mudah. Memutuskan akun di sini tidak mencabut akses resmi sebelumnya. Cabut akses dari pengaturan profil Anda pada akun sosial yang terhubung.',
+    'users_social_connect' => 'Hubungkan Akun',
+    'users_social_disconnect' => 'Putuskan Sambungan Akun',
+    'users_social_connected' => ':socialAccount akun berhasil dilampirkan ke profil Anda.',
+    'users_social_disconnected' => ':socialAccount akun berhasil diputuskan dari profil Anda.',
+    'users_api_tokens' => 'Token API',
+    'users_api_tokens_none' => 'Tidak ada token API yang telah dibuat untuk pengguna ini',
+    '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',
+    'user_api_token_name' => 'Nama',
+    'user_api_token_name_desc' => 'Berikan token Anda nama yang dapat dibaca sebagai pengingat masa depan akan tujuan yang dimaksudkan.',
+    'user_api_token_expiry' => 'Tanggal kadaluarsa',
+    'user_api_token_expiry_desc' => 'Setel tanggal token ini kedaluwarsa. Setelah tanggal ini, permintaan yang dibuat menggunakan token ini tidak akan berfungsi lagi. Mengosongkan bidang ini akan menetapkan masa berlaku 100 tahun ke depan.',
+    'user_api_token_create_secret_message' => 'Segera setelah membuat token ini, "Token ID" & "Token Secret" akan dibuat dan ditampilkan. Rahasianya hanya akan ditampilkan satu kali jadi pastikan untuk menyalin nilainya ke tempat yang aman dan terlindungi sebelum melanjutkan.',
+    'user_api_token_create_success' => 'Token API berhasil dibuat',
+    '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 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',
+    'user_api_token_updated' => 'Token diperbarui :timeAgo',
+    'user_api_token_delete' => 'Hapus Token',
+    'user_api_token_delete_warning' => 'Ini akan sepenuhnya menghapus token API ini dengan nama \': tokenName\' dari sistem.',
+    'user_api_token_delete_confirm' => 'Anda yakin ingin menghapus token API ini?',
+    'user_api_token_delete_success' => 'Token API berhasil dihapus',
+
+    //! 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' => 'Katalan',
+        'cs' => 'Česky',
+        'da' => 'Dansk',
+        'de' => 'Deutsch (Sie)',
+        'de_informal' => 'Deutsch (Du)',
+        'es' => 'Español',
+        'es_AR' => 'Español Argentina',
+        '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/id/validation.php b/resources/lang/id/validation.php
new file mode 100644 (file)
index 0000000..992f403
--- /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 harus diterima.',
+    'active_url'           => ':attribute bukan URL yang valid.',
+    'after'                => ':attribute harus setelah tanggal :date.',
+    'alpha'                => ':attribute hanya boleh berisi huruf.',
+    '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.',
+        'file'    => ':attribute harus diantara :min dan :max kilobyte.',
+        'string'  => ':attribute harus memiliki karakter antara :min dan :max.',
+        'array'   => ':attribute harus memiliki item antara :min dan :max.',
+    ],
+    'boolean'              => ':attribute bidang harus berisi benar atau salah.',
+    'confirmed'            => ':attribute konfirmasi tidak sama.',
+    'date'                 => ':attribute bukan tanggal yang valid.',
+    'date_format'          => ':attribute tidak sesuai dengan format :format.',
+    'different'            => ':attribute dan :other harus berbeda.',
+    'digits'               => ':attribute harus :digits digit.',
+    'digits_between'       => ':attribute harus diantara :min dan :max digit.',
+    'email'                => ':attrtibute Harus alamat e-mail yang valid.',
+    'ends_with' => ':attribute harus diakhiri dengan salah satu dari berikut ini: :values',
+    'filled'               => ':attribute bidang diperlukan.',
+    'gt'                   => [
+        'numeric' => ':attribute harus lebih besar dari :value.',
+        'file'    => ':attribute harus lebih besar dari :value kilobyte.',
+        'string'  => ':attribute harus lebih besar dari :value karakter.',
+        'array'   => ':attribute harus memiliki lebih dari item :value.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute harus lebih besar dari atau sama dengan :value.',
+        'file'    => ':attribute harus lebih besar dari atau sama dengan :value kilobyte.',
+        'string'  => ':attribute harus lebih besar dari atau sama dengan karakter :value.',
+        'array'   => ':attribute harus memiliki :value item atau lebih.',
+    ],
+    'exists'               => ':attribute yang dipilih tidak valid.',
+    'image'                => ':attribute harus berupa gambar.',
+    'image_extension'      => ':attribute harus memiliki ekstensi gambar yang valid & didukung.',
+    'in'                   => ':attribute yang dipilih tidak valid.',
+    'integer'              => ':attribute harus berupa bilangan bulat.',
+    'ip'                   => ':attribute harus berupa alamat IP yang valid.',
+    'ipv4'                 => ':attribute harus berupa alamat IPv4 yang valid.',
+    'ipv6'                 => ':attribute harus berupa alamat IPv6 yang valid.',
+    'json'                 => ':attribute harus berupa string JSON yang valid.',
+    'lt'                   => [
+        'numeric' => ':attribute harus kurang dari :value.',
+        'file'    => ':attribute harus kurang dari :value kilobyte.',
+        'string'  => ':attribute harus kurang dari :value karakter.',
+        'array'   => ':attribute harus memiliki kurang dari :value item.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute harus kurang dari atau sama dengan :value.',
+        'file'    => ':attribute harus kurang dari atau sama dengan :value kilobyte.',
+        'string'  => ':attribute harus kurang dari atau sama dengan :value karakter.',
+        'array'   => ':attribute tidak boleh memiliki lebih dari :value item.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute tidak boleh lebih dari :max.',
+        'file'    => ':attribute tidak boleh lebih dari :max kilobyte.',
+        'string'  => ':attribute tidak boleh lebih dari :max karakter.',
+        'array'   => ':attribute tidak boleh memiliki lebih dari :max item.',
+    ],
+    'mimes'                => ':attribute harus berupa file dengan tipe: :value.',
+    'min'                  => [
+        'numeric' => ':attribute minimal harus :min.',
+        'file'    => ':attribute minimal harus :min kilobyte.',
+        'string'  => ':attribute setidaknya harus :min karakter.',
+        'array'   => ':attribute minimal harus memiliki :min item.',
+    ],
+    'not_in'               => ':attribute yang dipilih tidak valid.',
+    'not_regex'            => ':attribute format tidak valid.',
+    'numeric'              => ':attribute harus berupa nomot.',
+    'regex'                => 'Format :attribute tidak valid.',
+    'required'             => ':attribute bidang harus diisi.',
+    'required_if'          => ':attribute Bidang harus diisi saat :other atau :value.',
+    'required_with'        => 'Bidang :attribute harus diisi jika ada :nilai.',
+    'required_with_all'    => 'Bidang :attribute harus diisi jika ada :values.',
+    'required_without'     => 'Bidang :attribute harus diisi jika :values tidak ada.',
+    'required_without_all' => 'Bidang :attribute harus diisi jika tidak ada :value yang ada.',
+    'same'                 => ':attribute dan :other harus sama.',
+    'safe_url'             => 'Tautan yang diberikan mungkin tidak aman.',
+    'size'                 => [
+        'numeric' => ':attribute harus berukuran :size.',
+        'file'    => ':attribute harus berukuran :size kilobyte.',
+        'string'  => ':attribute harus memiliki karakter berukuran :size.',
+        'array'   => ':attribute harus mengandung :size item.',
+    ],
+    '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'             => 'Berkas tidak dapat diunggah. Server mungkin tidak menerima berkas dengan ukuran ini.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Konfirmasi kata sandi diperlukan',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index c66651489394fc5b0ea34c8eda4d37729a93583c..96852f9220f9e6d26150abb65f6ec3b6bbf6df72 100755 (executable)
@@ -6,7 +6,7 @@
 return [
 
     // Pages
-    'page_create'                 => 'ha creato la pagina',
+    'page_create'                 => 'pagina creata',
     'page_create_notification'    => 'Pagina Creata Correttamente',
     'page_update'                 => 'ha aggiornato la pagina',
     'page_update_notification'    => 'Pagina Aggiornata Correttamente',
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'ha eliminato la libreria',
     'bookshelf_delete_notification'    => 'Libreria Eliminata Correttamente',
 
+    // 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',
+
     // Other
     'commented_on'                => 'ha commentato in',
+    'permissions_update'          => 'autorizzazioni aggiornate',
 ];
index 8e8ff8f0780c3e35b60ce0ae0fe025905308af9b..3e1500a6ff3b0b2cf7eb243cb61a7fa48959f292 100755 (executable)
@@ -43,7 +43,7 @@ return [
     '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.',
     'reset_password_send_button' => 'Invia Link Reset',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'Un link di reset della password verrà inviato a :email se la mail verrà trovata nel sistema.',
     'reset_password_success' => 'La tua password è stata resettata correttamente.',
     'email_reset_subject' => 'Reimposta la password di :appName',
     'email_reset_text' => 'Stai ricevendo questa mail perché abbiamo ricevuto una richiesta di reset della password per il tuo account.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Password impostata, ora hai accesso 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' => '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' => 'Metodo multi-fattore configurato, si prega di effettuare nuovamente il login utilizzando il metodo configurato.',
 ];
\ No newline at end of file
index 73b4cad54276b06971feb38ce98aafa8bbd0e931..bcd3aadf92ad6f251e3b1923af9fd9f9f62afedd 100755 (executable)
@@ -19,7 +19,7 @@ return [
     'description' => 'Descrizione',
     'role' => 'Ruolo',
     'cover_image' => 'Immagine di copertina',
-    'cover_image_description' => 'Questa immagine dovrebbe essere approssimatamente 440x250px.',
+    'cover_image_description' => 'Questa immagine dovrebbe essere approssimativamente 440x250px.',
     
     // Actions
     'actions' => 'Azioni',
@@ -33,12 +33,18 @@ return [
     'copy' => 'Copia',
     'reply' => 'Rispondi',
     'delete' => 'Elimina',
+    'delete_confirm' => 'Conferma Eliminazione',
     'search' => 'Cerca',
     'search_clear' => 'Pulisci Ricerca',
     'reset' => 'Azzera',
     'remove' => 'Rimuovi',
     'add' => 'Aggiungi',
+    'configure' => 'Configura',
     'fullscreen' => 'Schermo intero',
+    'favourite' => 'Aggiungi ai Preferiti',
+    'unfavourite' => 'Rimuovi dai preferiti',
+    'next' => 'Successivo',
+    'previous' => 'Precedente',
 
     // Sort Options
     'sort_options' => 'Opzioni Ordinamento',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Ordine Ascendente',
     'sort_descending' => 'Ordine Discendente',
     'sort_name' => 'Nome',
+    'sort_default' => 'Predefinito',
     'sort_created_at' => 'Data Creazione',
     'sort_updated_at' => 'Data Aggiornamento',
 
@@ -54,6 +61,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,17 +71,25 @@ return [
     'breadcrumb' => 'Navigazione',
 
     // Header
-    'profile_menu' => 'Menu del profilo',
+    'header_menu_expand' => 'Espandi Menù Intestazione',
+    'profile_menu' => 'Menù del profilo',
     'view_profile' => 'Visualizza Profilo',
     'edit_profile' => 'Modifica Profilo',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Modalità Scura',
+    'light_mode' => 'Modalità Chiara',
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: Mostra Informazioni Secondarie',
     'tab_content' => 'Contenuto',
+    'tab_content_label' => 'Tab: Mostra Contenuto Principale',
 
     // Email Content
     'email_action_help' => 'Se hai problemi nel cliccare il pulsante ":actionText", copia e incolla lo URL sotto nel tuo browser:',
     'email_rights' => 'Tutti i diritti riservati',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Norme sulla privacy',
+    'terms_of_service' => 'Condizioni del Servizio',
 ];
index 360409646e3040033dd88acad388bd8a5870604d..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' => 'Clicca elimina nuovamente per confermare.',
+    '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',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Modifica Codice',
     'code_language' => 'Linguaggio Codice',
     'code_content' => 'Contenuto Codice',
+    'code_session_history' => 'Cronologia Sessione',
     'code_save' => 'Salva Codice',
 ];
index 9f6acd133950a644808cb668c5d20a8b0956a7b1..2088ed1ff4ce0244fbb53a6bc20a22eaf756ca39 100755 (executable)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Creato :timeLength da :user',
     'meta_updated' => 'Aggiornato :timeLength',
     'meta_updated_name' => 'Aggiornato :timeLength da :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',
@@ -33,12 +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' => 'Proprietario',
 
     // Search
     'search_results' => 'Risultati Ricerca',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Nessuna pagina corrisponde alla ricerca',
     'search_for_term' => 'Ricerca per :term',
     'search_more' => 'Più Risultati',
-    'search_filters' => 'Filtri Ricerca',
+    'search_advanced' => 'Ricerca Avanzata',
+    'search_terms' => 'Termini Ricerca',
     'search_content_type' => 'Tipo di Contenuto',
     'search_exact_matches' => 'Corrispondenza Esatta',
     'search_tags' => 'Ricerche Tag',
@@ -57,6 +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' => 'Creati da me',
     'search_date_options' => 'Opzioni Data',
     'search_updated_before' => 'Aggiornati prima del',
     'search_updated_after' => 'Aggiornati dopo il',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Crea un nuovo capitolo',
     'chapters_delete' => 'Elimina Capitolo',
     'chapters_delete_named' => 'Elimina il capitolo :chapterName',
-    'chapters_delete_explain' => 'Questo eliminerà il capitolo \':chapterName\'. Tutte le pagine verranno spostate nel libro.',
+    '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',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Versioni Pagina',
     'pages_revisions_named' => 'Versioni della pagina :pageName',
     'pages_revision_named' => 'Versione della pagina :pageName',
+    'pages_revision_restored_from' => 'Ripristinato da #:id; :summary',
     'pages_revisions_created_by' => 'Creata Da',
     'pages_revisions_date' => 'Data Versione',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Carica File',
     'attachments_link' => 'Allega Link',
     'attachments_set_link' => 'Imposta Link',
-    'attachments_delete_confirm' => 'Clicca elimina nuovamente per confermare l\'eliminazione di questo allegato.',
+    'attachments_delete' => 'Sei sicuro di voler eliminare questo allegato?',
     'attachments_dropzone' => 'Rilascia file o clicca qui per allegare un file',
     'attachments_no_files' => 'Nessun file è stato caricato',
     'attachments_explain_link' => 'Puoi allegare un link se preferisci non caricare un file. Questo può essere un link a un\'altra pagina o a un file nel cloud.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Link al file',
     'attachments_link_url_hint' => 'Url del sito o del file',
     'attach' => 'Allega',
+    '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',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index ca605cde0c9c71c90dd76f9be2ee12ec2fa31431..9a20c744b7501c358c681e67fbca0ae3976b0455 100755 (executable)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Il caricamento del file è andato in timeout.',
 
     // Attachments
-    'attachment_page_mismatch' => 'La pagina non è corrisposta durante l\'aggiornamento dell\'allegato',
     'attachment_not_found' => 'Allegato non trovato',
 
     // Pages
@@ -84,20 +83,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 34342d3c73f821eb1510f6cc6ab4e500f2d37079..c5e016b35c835af8b8bfa065af89e1a501c4ec47 100755 (executable)
@@ -37,6 +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' => '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. ',
@@ -44,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',
@@ -56,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.',
@@ -68,7 +73,7 @@ return [
     '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_image_cleanup_ignore_revisions' => 'Ignora le immagini nelle revisioni',
+    '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!',
@@ -80,6 +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' => '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' => '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' => 'Al momento il cestino è vuoto',
+    'recycle_bin_empty' => 'Svuota Cestino',
+    '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' => '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' => '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',
@@ -96,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',
@@ -103,9 +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' => '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',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Profilo Utente',
     'users_add_new' => 'Aggiungi Nuovo Utente',
     'users_search' => 'Cerca Utenti',
+    '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.',
@@ -131,14 +178,17 @@ return [
     '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_delete_success' => 'Utenti rimossi correttamente',
+    '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',
     'users_edit_profile' => 'Modifica Profilo',
     'users_edit_success' => 'Utente aggiornato correttamente',
@@ -152,32 +202,36 @@ 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_create' => 'Create Token',
-    'users_api_tokens_expires' => 'Expires',
-    'users_api_tokens_docs' => 'API Documentation',
+    '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' => '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' => 'Create API Token',
+    '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' => 'API Token',
+    '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_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' => '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' => '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',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Catalano',
         'cs' => 'Česky',
         'da' => 'Danese',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 3b85303d2b879477e83f4309db786bfde8b152ed..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'Il campo :attribute deve essere almeno :min caratteri.',
         'array'   => 'Il campo :attribute deve contenere almeno :min elementi.',
     ],
-    'no_double_extension'  => ':attribute deve avere solo un\'estensione.',
     'not_in'               => 'Il :attribute selezionato non è valido.',
     'not_regex'            => 'Il formato di :attribute non è valido.',
     'numeric'              => ':attribute deve essere un numero.',
@@ -90,6 +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'             => '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 de1ca1ac6c9cdea7475e8acc830ca5ed1856635c..3dc749b6746d3621dcf612292e933fb124a0fc0e 100644 (file)
@@ -36,13 +36,22 @@ return [
     '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" 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',
 
     // Other
-    'commented_on'                => 'commented on',
+    'commented_on'                => 'コメントする',
+    'permissions_update'          => 'updated permissions',
 ];
index a700ffb95a2e50329eab8e4761c2a294a5b2b564..8dcac7aa4ed8a81a4fd582cd16003f9977b3d2b9 100644 (file)
@@ -26,8 +26,8 @@ return [
     'remember_me' => 'ログイン情報を保存する',
     'ldap_email_hint' => 'このアカウントで使用するEメールアドレスを入力してください。',
     'create_account' => 'アカウント作成',
-    'already_have_account' => 'Already have an account?',
-    'dont_have_account' => 'Don\'t have an account?',
+    'already_have_account' => 'すでにアカウントをお持ちですか?',
+    'dont_have_account' => '初めての登録ですか?',
     'social_login' => 'SNSログイン',
     'social_registration' => 'SNS登録',
     'social_registration_text' => '他のサービスで登録 / ログインする',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Password set, you now have access to :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.',
 ];
\ No newline at end of file
index 9611e232b532a487e5cc5951d940177e6831912f..a5f2f942984580cc946a390e927bd45b9a215fea 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Copy',
     'reply' => '返信',
     'delete' => '削除',
+    'delete_confirm' => 'Confirm Deletion',
     'search' => '検索',
     'search_clear' => '検索をクリア',
     'reset' => 'リセット',
     'remove' => '削除',
     'add' => '追加',
+    'configure' => 'Configure',
     'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => 'Sort Options',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Sort Ascending',
     'sort_descending' => 'Sort Descending',
     'sort_name' => 'Name',
+    'sort_default' => 'Default',
     'sort_created_at' => 'Created Date',
     'sort_updated_at' => 'Updated Date',
 
@@ -54,6 +61,7 @@ return [
     'no_activity' => '表示するアクティビティがありません',
     'no_items' => 'アイテムはありません',
     'back_to_top' => '上に戻る',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => '概要の表示切替',
     'toggle_thumbnails' => 'Toggle Thumbnails',
     'details' => '詳細',
@@ -63,6 +71,7 @@ return [
     'breadcrumb' => 'Breadcrumb',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profile Menu',
     'view_profile' => 'プロフィール表示',
     'edit_profile' => 'プロフィール編集',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Content',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => '":actionText" をクリックできない場合、以下のURLをコピーしブラウザで開いてください:',
     'email_rights' => 'All rights reserved',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
 ];
index 7cc560b43cfcb692cae22a1824f9bccd25f9838e..c4e44433787858f367e72133eba3dfab288b49e2 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'さらに読み込む',
     'image_image_name' => '画像名',
     'image_delete_used' => 'この画像は以下のページで利用されています。',
-    'image_delete_confirm' => '削除してもよろしければ、再度ボタンを押して下さい。',
+    'image_delete_confirm_text' => 'Are you sure you want to delete this image?',
     'image_select_image' => '画像を選択',
     'image_dropzone' => '画像をドロップするか、クリックしてアップロード',
     'images_deleted' => '画像を削除しました',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'コードを編集する',
     'code_language' => 'プログラミング言語の選択',
     'code_content' => 'プログラム内容',
+    'code_session_history' => 'セッション履歴',
     'code_save' => 'プログラムを保存',
 ];
index 4f1a855ff78a2a1414ce4082490716049f488edd..1c9dafc438ae1e0a76925a446bc0d02327801c20 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => '作成: :timeLength (:user)',
     'meta_updated' => '更新: :timeLength',
     'meta_updated_name' => '更新: :timeLength (:user)',
+    'meta_owned_name' => 'Owned by :user',
     'entity_select' => 'エンティティ選択',
     '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' => '最近更新されたページはありません。',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Webページ',
     'export_pdf' => 'PDF',
     'export_text' => 'テキストファイル',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => '権限',
     'permissions_intro' => 'この設定は各ユーザの役割よりも優先して適用されます。',
     'permissions_enable' => 'カスタム権限設定を有効にする',
     'permissions_save' => '権限を保存',
+    'permissions_owner' => 'Owner',
 
     // Search
     'search_results' => '検索結果',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'ページが見つかりませんでした。',
     'search_for_term' => ':term の検索結果',
     'search_more' => 'さらに表示',
-    'search_filters' => '検索フィルタ',
+    'search_advanced' => 'Advanced Search',
+    'search_terms' => 'Search Terms',
     'search_content_type' => '種類',
     'search_exact_matches' => '完全一致',
     'search_tags' => 'タグ検索',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => '権限が設定されている',
     'search_created_by_me' => '自分が作成した',
     'search_updated_by_me' => '自分が更新した',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => 'Date Options',
     'search_updated_before' => '以前に更新',
     'search_updated_after' => '以降に更新',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'チャプターを作成',
     'chapters_delete' => 'チャプターを削除',
     'chapters_delete_named' => 'チャプター「:chapterName」を削除',
-    'chapters_delete_explain' => 'チャプター「: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' => 'チャプターを削除してよろしいですか?',
     'chapters_edit' => 'チャプターを編集',
     'chapters_edit_named' => 'チャプター「:chapterName」を編集',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => '編集履歴',
     'pages_revisions_named' => ':pageName のリビジョン',
     'pages_revision_named' => ':pageName のリビジョン',
+    'pages_revision_restored_from' => 'Restored from #:id; :summary',
     'pages_revisions_created_by' => '作成者',
     'pages_revisions_date' => '日付',
     'pages_revisions_number' => 'リビジョン',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'アップロード',
     'attachments_link' => 'リンクを添付',
     'attachments_set_link' => 'リンクを設定',
-    'attachments_delete_confirm' => 'もう一度クリックし、削除を確認してください。',
+    'attachments_delete' => 'Are you sure you want to delete this attachment?',
     'attachments_dropzone' => 'ファイルをドロップするか、クリックして選択',
     'attachments_no_files' => 'ファイルはアップロードされていません',
     'attachments_explain_link' => 'ファイルをアップロードしたくない場合、他のページやクラウド上のファイルへのリンクを添付できます。',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'ファイルURL',
     'attachments_link_url_hint' => 'WebサイトまたはファイルへのURL',
     'attach' => '添付',
+    'attachments_insert_link' => 'Add Attachment Link to Page',
     'attachments_edit_file' => 'ファイルを編集',
     'attachments_edit_file_name' => 'ファイル名',
     'attachments_edit_drop_upload' => 'ファイルをドロップするか、クリックしてアップロード',
@@ -311,4 +321,4 @@ return [
     'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
     'revision_delete_success' => 'リビジョンを削除しました',
     'revision_cannot_delete_latest' => '最新のリビジョンを削除できません。'
-];
\ No newline at end of file
+];
index ace2f77bd3d6d9947de0a707ee4b525ed35eccaa..4d1776f1296380e980e2eac7346f3c0c8dd15e38 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'ファイルのアップロードがタイムアウトしました。',
 
     // Attachments
-    'attachment_page_mismatch' => '添付を更新するページが一致しません',
     'attachment_not_found' => '添付ファイルが見つかりません。',
 
     // Pages
@@ -84,6 +83,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 c7a9773e308735c48417460609209c77c5dafba7..c769174e7be40e5de2b5e733639c78cad7afa795 100644 (file)
@@ -12,18 +12,18 @@ return [
     'settings_save_success' => '設定を保存しました',
 
     // App Settings
-    'app_customization' => 'Customization',
-    'app_features_security' => 'Features & Security',
+    'app_customization' => 'カスタマイズ',
+    'app_features_security' => '機能とセキュリティ',
     'app_name' => 'アプリケーション名',
     'app_name_desc' => 'この名前はヘッダーやEメール内で表示されます。',
     'app_name_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_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' => 'Enable higher security image uploads',
+    'app_secure_images_toggle' => 'より高いセキュリティの画像アップロードを可能にする',
     'app_secure_images_desc' => 'パフォーマンスの観点から、全ての画像が公開になっています。このオプションを有効にすると、画像URLの先頭にランダムで推測困難な文字列が追加され、アクセスを困難にします。',
     'app_editor' => 'ページエディタ',
     'app_editor_desc' => 'ここで選択されたエディタを全ユーザが使用します。',
@@ -36,14 +36,19 @@ return [
     '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_select' => 'Select a page',
+    '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_label' => '表示するテキスト',
+    'app_footer_links_url' => 'リンク先の URL',
+    'app_footer_links_add' => 'Add Footer Link',
     'app_disable_comments' => 'コメントを無効にする',
-    'app_disable_comments_toggle' => 'Disable comments',
+    'app_disable_comments_toggle' => 'コメントを無効にする',
     'app_disable_comments_desc' => 'アプリケーション内のすべてのページのコメントを無効にします。既存のコメントは表示されません。',
 
     // 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.',
+    'content_colors' => 'コンテンツの色',
+    'content_colors_desc' => 'ページ構成階層のすべての要素に色を設定します。読みやすさを考慮して、デフォルトの色と同じような明るさの色を選ぶことをお勧めします。',
     'bookshelf_color' => 'Shelf Color',
     'book_color' => 'Book Color',
     'chapter_color' => 'Chapter Color',
@@ -52,34 +57,72 @@ return [
 
     // Registration Settings
     'reg_settings' => '登録設定',
-    'reg_enable' => 'Enable Registration',
-    'reg_enable_toggle' => 'Enable registration',
+    '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_default_role' => '新規登録時のデフォルト役割',
-    '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' => '外部のLDAPまたはSAML認証が有効の場合、上記のオプションは無視されます。存在しないメンバーのユーザーアカウントは、使用している外部システムでの認証に成功した場合に自動的に作成されます。',
+    'reg_email_confirmation' => '確認メール',
+    'reg_email_confirmation_toggle' => 'メールによる確認を行う',
     'reg_confirm_email_desc' => 'ドメイン制限を有効にしている場合はEメール認証が必須となり、この項目は無視されます。',
     'reg_confirm_restrict_domain' => 'ドメイン制限',
     'reg_confirm_restrict_domain_desc' => '特定のドメインのみ登録できるようにする場合、以下にカンマ区切りで入力します。設定された場合、Eメール認証が必須になります。<br>登録後、ユーザは自由にEメールアドレスを変更できます。',
     'reg_confirm_restrict_domain_placeholder' => '制限しない',
 
     // Maintenance settings
-    'maint' => 'Maintenance',
+    'maint' => 'メンテナンス',
     '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_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?',
+    '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_send_test_email' => 'Send a Test Email',
+    '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_mail_subject' => 'Test Email',
+    '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',
+
+    // 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_parent' => 'Parent',
+    '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_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.',
+
+    // 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_ip' => 'IP Address',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
 
     // Role Settings
     'roles' => '役割',
@@ -96,6 +139,7 @@ return [
     'role_details' => '概要',
     'role_name' => '役割名',
     'role_desc' => '役割の説明',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'External Authentication IDs',
     'role_system' => 'システム権限',
     'role_manage_users' => 'ユーザ管理',
@@ -105,7 +149,9 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system 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' => '各アセットに対するデフォルトの権限を設定します。ここで設定した権限が優先されます。',
     'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
     'role_all' => '全て',
@@ -121,12 +167,13 @@ return [
     '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_password' => 'User Password',
+    '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',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => 'ユーザ「:userName」を削除',
     'users_delete_warning' => 'ユーザ「:userName」を完全に削除します。',
     'users_delete_confirm' => '本当にこのユーザを削除してよろしいですか?',
-    'users_delete_success' => 'ユーザを削除しました',
+    '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_edit_profile' => 'プロフィール編集',
     'users_edit_success' => 'ユーザを更新しました',
@@ -157,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',
@@ -164,7 +218,7 @@ return [
     '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_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',
@@ -172,8 +226,8 @@ return [
     '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_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?',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 231bdfa0b8f34f6ac7098f2ba1304d4ce465e6c5..2ef8b011912bd8c87cdb2d026d3ebd0cd2914234 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である必要があります。',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attributeは:min文字以上である必要があります。',
         'array'   => ':attributeは:min個以上である必要があります。',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
     'not_in'               => '選択された:attributeは不正です。',
     'not_regex'            => 'The :attribute format is invalid.',
     'numeric'              => ':attributeは数値である必要があります。',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':valuesが設定されていない場合、:attributeは必須です。',
     'required_without_all' => ':valuesが設定されていない場合、:attributeは必須です。',
     'same'                 => ':attributeと:otherは一致している必要があります。',
+    'safe_url'             => 'The provided link may not be safe.',
     'size'                 => [
         'numeric' => ':attributeは:sizeである必要があります。',
         'file'    => ':attributeは:sizeキロバイトである必要があります。',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attributeは文字列である必要があります。',
     'timezone'             => ':attributeは正しいタイムゾーンである必要があります。',
+    'totp'                 => 'The provided code is not valid or has expired.',
     'unique'               => ':attributeは既に使用されています。',
     'url'                  => ':attributeのフォーマットは不正です。',
     'uploaded'             => 'The file could not be uploaded. The server may not accept files of this size.',
index c3fedeb45ac8ec0c1f2698c8a4495629e065b153..ee694f0736b351cf06c03f5c5df06fe0c7c9b2f8 100644 (file)
@@ -10,27 +10,27 @@ return [
     'page_create_notification'    => '문서 만듦',
     'page_update'                 => '문서 수정',
     'page_update_notification'    => '문서 수정함',
-    'page_delete'                 => '문서 지우기',
+    'page_delete'                 => '삭제 된 페이지',
     'page_delete_notification'    => '문서 지움',
     'page_restore'                => '문서 복원',
     'page_restore_notification'   => '문서 복원함',
-    'page_move'                   => '문ì\84\9c ì\98®ê¸°ê¸°',
+    'page_move'                   => '문ì\84\9c ì\9d´ë\8f\84á\86¼ë\90¨',
 
     // Chapters
     'chapter_create'              => '챕터 만들기',
     'chapter_create_notification' => '챕터 만듦',
     'chapter_update'              => '챕터 바꾸기',
     'chapter_update_notification' => '챕터 바꿈',
-    'chapter_delete'              => 'ì±\95í\84° ì§\80ì\9a°ê¸°',
+    'chapter_delete'              => 'ì\82­ì \9cë\90\9c ì±\95í\84°',
     'chapter_delete_notification' => '챕터 지움',
-    'chapter_move'                => 'ì±\95í\84° ì\98®ê¸°ê¸°',
+    'chapter_move'                => 'ì±\95í\84° ì\9d´ë\8f\99ë\90\9c',
 
     // Books
     'book_create'                 => '책자 만들기',
     'book_create_notification'    => '책자 만듦',
     'book_update'                 => '책자 바꾸기',
     'book_update_notification'    => '책자 바꿈',
-    'book_delete'                 => 'ì±\85ì\9e\90 ì§\80ì\9a°ê¸°',
+    'book_delete'                 => 'ì\82­ì \9c ë\90\9c ì±\85ì\9e\90',
     'book_delete_notification'    => '책자 지움',
     'book_sort'                   => '책자 정렬',
     'book_sort_notification'      => '책자 정렬함',
@@ -40,9 +40,18 @@ return [
     'bookshelf_create_notification'    => '서가 만듦',
     'bookshelf_update'                 => '서가 바꾸기',
     'bookshelf_update_notification'    => '서가 바꿈',
-    'bookshelf_delete'                 => 'ì\84\9cê°\80 ì§\80ì\9a°ê¸°',
+    'bookshelf_delete'                 => 'ì\82­ì \9cë\90\9c ì\84\9cê°\80',
     'bookshelf_delete_notification'    => '서가 지움',
 
+    // 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',
+
     // Other
     'commented_on'                => '댓글 쓰기',
+    'permissions_update'          => 'updated permissions',
 ];
index 9c4d98bcb01be1d3b40624e686e63f39ec7d1f31..ce65f3ecc86bd369ef485000366242fea8fef582 100644 (file)
@@ -6,8 +6,8 @@
  */
 return [
 
-    'failed' => '가입하지 않았거나 비밀번호가 틀립니다.',
-    'throttle' => '여러 번 실패했습니다. :seconds초 후에 다시 시도하세요.',
+    'failed' => '자격 증명이 기록과 일치하지 않습니다.',
+    'throttle' => '로그인 시도가 너무 많습니다. :seconds초 후에 다시 시도하세요.',
 
     // Login & Register
     'sign_up' => '가입',
@@ -43,7 +43,7 @@ return [
     'reset_password' => '비밀번호 바꾸기',
     'reset_password_send_instructions' => '메일 주소를 입력하세요. 이 주소로 해당 과정을 위한 링크를 보낼 것입니다.',
     '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' => '비밀번호를 바꿉니다.',
@@ -70,8 +70,43 @@ return [
     'user_invite_email_greeting' => ':appName에서 가입한 기록이 있습니다.',
     'user_invite_email_text' => '다음 버튼을 눌러 확인하세요:',
     'user_invite_email_action' => '비밀번호 설정',
-    'user_invite_page_welcome' => ':appName로 접속했습니다.',
+    'user_invite_page_welcome' => ':appName에 오신 것을 환영합니다!',
     'user_invite_page_text' => ':appName에 로그인할 때 입력할 비밀번호를 설정하세요.',
     'user_invite_page_confirm_button' => '비밀번호 확인',
-    'user_invite_success' => '이제 :appName에 접근할 수 있습니다.'
+    'user_invite_success' => '암호가 설정되었고, 이제 :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.',
 ];
\ No newline at end of file
index 67cc87bd23546e8ed94991fc6d239bd50a1cb386..2349ab735d06969ffe65d0c845a4757ef815a1ce 100644 (file)
@@ -29,16 +29,22 @@ return [
     'update' => '바꾸기',
     'edit' => '수정',
     'sort' => '정렬',
-    'move' => 'ì\98®ê¸°ê¸°',
+    'move' => 'ì\9d´ë\8f\99',
     'copy' => '복사',
     'reply' => '답글',
-    'delete' => '지우기',
+    'delete' => '삭제',
+    'delete_confirm' => '삭제 요청 확인',
     'search' => '검색',
-    'search_clear' => '기ë¡\9d 지우기',
+    'search_clear' => 'ê²\80ì\83\89 지우기',
     'reset' => '리셋',
     'remove' => '제거',
     'add' => '추가',
+    'configure' => 'Configure',
     'fullscreen' => '전체화면',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => '정렬 기준',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => '오름차 순서',
     'sort_descending' => '내림차 순서',
     'sort_name' => '제목',
+    'sort_default' => 'Default',
     'sort_created_at' => '만든 날짜',
     'sort_updated_at' => '수정한 날짜',
 
@@ -54,6 +61,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,7 @@ return [
     'breadcrumb' => '탐색 경로',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => '프로필',
     'view_profile' => '프로필 보기',
     'edit_profile' => '프로필 바꾸기',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => '정보',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => '내용',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => ':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',
 ];
index 397d0d1879c12168090f7129c21b4b761755fd27..9155b9490ee6f531db61753c5a9d7a8363c80926 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => '더 로드하기',
     'image_image_name' => '이미지 이름',
     'image_delete_used' => '이 이미지는 다음 문서들이 쓰고 있습니다.',
-    'image_delete_confirm' => '이 이미지를 지울 건가요?',
+    'image_delete_confirm_text' => '이 이미지를 정말 삭제하시겠습니까?',
     'image_select_image' => '이미지 선택',
     'image_dropzone' => '여기에 이미지를 드롭하거나 여기를 클릭하세요. 이미지를 올릴 수 있습니다.',
     'images_deleted' => '이미지 삭제함',
@@ -29,5 +29,6 @@ return [
     'code_editor' => '코드 수정',
     'code_language' => '언어',
     'code_content' => '내용',
+    'code_session_history' => '세션 기록',
     'code_save' => '저장',
 ];
index a166cda40557cf05b9d55eba5b1a85d2aeef6ec6..aa25aa64614d479d8338da7b1652e2ed598b7602 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => '만듦 :timeLength, :user',
     'meta_updated' => '수정함 :timeLength',
     'meta_updated_name' => '수정함 :timeLength, :user',
+    'meta_owned_name' => 'Owned by :user',
     'entity_select' => '항목 선택',
     'images' => '이미지',
-    'my_recent_drafts' => '쓰다 만 문서',
+    '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' => '문서 없음',
@@ -33,21 +36,24 @@ return [
     'export_html' => 'Contained Web(.html) 파일',
     'export_pdf' => 'PDF 파일',
     'export_text' => 'Plain Text(.txt) 파일',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => '권한',
     'permissions_intro' => '한번 허용하면 이 설정은 사용자 권한에 우선합니다.',
     'permissions_enable' => '설정 허용',
     'permissions_save' => '권한 저장',
+    'permissions_owner' => 'Owner',
 
     // Search
     'search_results' => '검색 결과',
     'search_total_results_found' => ':count개|총 :count개',
-    'search_clear' => '기ë¡\9d 지우기',
+    'search_clear' => 'ê²\80ì\83\89 지우기',
     'search_no_pages' => '결과 없음',
     'search_for_term' => ':term 검색',
     'search_more' => '더 많은 결과',
-    'search_filters' => '고급 검색',
+    'search_advanced' => '고급 검색',
+    'search_terms' => '용어 검색',
     'search_content_type' => '형식',
     'search_exact_matches' => '정확히 일치',
     'search_tags' => '꼬리표 일치',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => '권한 설정함',
     'search_created_by_me' => '내가 만듦',
     'search_updated_by_me' => '내가 수정함',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => '날짜',
     'search_updated_before' => '이전에 수정함',
     'search_updated_after' => '이후에 수정함',
@@ -85,13 +92,14 @@ return [
     'shelves_edit_and_assign' => '서가 바꾸기로 책자를 추가하세요.',
     'shelves_edit_named' => ':name 바꾸기',
     'shelves_edit' => '서가 바꾸기',
-    'shelves_delete' => 'ì\84\9cê°\80 ì§\80ì\9a°기',
-    'shelves_delete_named' => ':name ì§\80ì\9a°기',
+    'shelves_delete' => 'ì\84\9cê°\80 ì\82­ì \9cí\95\98기',
+    'shelves_delete_named' => ':name ì\82­ì \9cí\95\98기',
     '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' => '서가의 모든 책자에 이 권한을 적용합니다. 서가의 권한을 저장했는지 확인하세요.',
@@ -109,7 +117,7 @@ return [
     'books_popular_empty' => '많이 읽은 책자 목록',
     'books_new_empty' => '새로운 책자 목록',
     'books_create' => '책자 만들기',
-    'books_delete' => 'ì±\85ì\9e\90 ì§\80ì\9a°기',
+    'books_delete' => 'ì±\85ì\9e\90 ì\82­ì \9cí\95\98기',
     'books_delete_named' => ':bookName(을)를 지웁니다.',
     'books_delete_explain' => ':bookName에 있는 모든 챕터와 문서도 지웁니다.',
     'books_delete_confirmation' => '이 책자를 지울 건가요?',
@@ -143,15 +151,15 @@ return [
     'chapters_popular' => '많이 읽은 챕터',
     'chapters_new' => '새로운 챕터',
     'chapters_create' => '챕터 만들기',
-    'chapters_delete' => 'ì±\95í\84° ì§\80ì\9a°기',
+    'chapters_delete' => 'ì±\95í\84° ì\82­ì \9cí\95\98기',
     'chapters_delete_named' => ':chapterName(을)를 지웁니다.',
-    'chapters_delete_explain' => ':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' => '이 챕터를 지울 건가요?',
     'chapters_edit' => '챕터 바꾸기',
     'chapters_edit_named' => ':chapterName 바꾸기',
     'chapters_save' => '저장',
-    'chapters_move' => 'ì±\95í\84° ì\98®ê¸°기',
-    'chapters_move_named' => ':chapterName ì\98®ê¸°기',
+    'chapters_move' => 'ì±\95í\84° ì\9d´ë\8f\99í\95\98기',
+    'chapters_move_named' => ':chapterName ì\9d´ë\8f\99í\95\98기',
     'chapter_move_success' => ':bookName(으)로 옮김',
     'chapters_permissions' => '챕터 권한',
     'chapters_empty' => '이 챕터에 문서가 없습니다.',
@@ -167,22 +175,22 @@ return [
     'pages_new' => '새로운 문서',
     'pages_attachments' => '첨부',
     'pages_navigation' => '목차',
-    'pages_delete' => '문ì\84\9c ì§\80ì\9a°기',
-    'pages_delete_named' => ':pageName ì§\80ì\9a°기',
-    'pages_delete_draft_named' => ':pageName ì§\80ì\9a°기',
-    'pages_delete_draft' => 'ì\93°ë\8b¤ ë§\8c ë¬¸ì\84\9c ì§\80ì\9a°기',
+    'pages_delete' => '문ì\84\9c ì\82­ì \9cí\95\98기',
+    'pages_delete_named' => ':pageName ì\82­ì \9cí\95\98기',
+    'pages_delete_draft_named' => ':pageName ì´\88ì\95\88 ë¬¸ì\84\9c ì\82­ì \9cí\95\98기',
+    'pages_delete_draft' => 'ì´\88ì\95\88 ë¬¸ì\84\9c ì\82­ì \9cí\95\98기',
     'pages_delete_success' => '문서 지움',
-    'pages_delete_draft_success' => 'ì\93°ë\8b¤ ë§\8c 문서 지움',
+    'pages_delete_draft_success' => 'ì´\88ì\95\88 문서 지움',
     'pages_delete_confirm' => '이 문서를 지울 건가요?',
-    'pages_delete_draft_confirm' => 'ì\93°ë\8b¤ ë§\8c ë¬¸ì\84\9c를 ì§\80ì\9a¸ 건가요?',
+    'pages_delete_draft_confirm' => 'ì´\88ì\95\88 ë¬¸ì\84\9c를 ì\82­ì \9cí\95  건가요?',
     'pages_editing_named' => ':pageName 수정',
-    'pages_edit_draft_options' => 'ì\93°ë\8b¤ ë§\8c ë¬¸ì\84\9c ì\84 í\83\9d',
-    'pages_edit_save_draft' => '보관',
-    'pages_edit_draft' => 'ì\93°ë\8b¤ ë§\8c 문서 수정',
-    'pages_editing_draft' => 'ì\93°ë\8b¤ ë§\8c 문서 수정',
+    'pages_edit_draft_options' => 'ì´\88ì\95\88 ë¬¸ì\84\9c ì\98µì\85\98',
+    'pages_edit_save_draft' => '초안으로 저장',
+    'pages_edit_draft' => 'ì´\88ì\95\88 문서 수정',
+    'pages_editing_draft' => 'ì´\88ì\95\88 문서 수정',
     'pages_editing_page' => '문서 수정',
     'pages_edit_draft_save_at' => '보관함: ',
-    'pages_edit_delete_draft' => '삭제',
+    'pages_edit_delete_draft' => 'ì´\88ì\95\88 ì\82­ì \9c',
     'pages_edit_discard_draft' => '폐기',
     'pages_edit_set_changelog' => '수정본 설명',
     'pages_edit_enter_changelog_desc' => '수정본 설명',
@@ -196,7 +204,7 @@ return [
     'pages_md_insert_link' => '내부 링크',
     'pages_md_insert_drawing' => '드로잉 추가',
     'pages_not_in_chapter' => '챕터에 있는 문서가 아닙니다.',
-    'pages_move' => '문ì\84\9c ì\98®ê¸°기',
+    'pages_move' => '문ì\84\9c ì\9d´ë\8f\99í\95\98기',
     'pages_move_success' => ':parentName(으)로 옮김',
     'pages_copy' => '문서 복제',
     'pages_copy_desination' => '복제할 위치',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => '문서 수정본',
     'pages_revisions_named' => ':pageName 수정본',
     'pages_revision_named' => ':pageName 수정본',
+    'pages_revision_restored_from' => 'Restored from #:id; :summary',
     'pages_revisions_created_by' => '만든 사용자',
     'pages_revisions_date' => '수정한 날짜',
     'pages_revisions_number' => 'No.',
@@ -223,8 +232,8 @@ return [
     'pages_permissions_active' => '문서 권한 허용함',
     'pages_initial_revision' => '처음 판본',
     'pages_initial_name' => '제목 없음',
-    'pages_editing_draft_notification' => ':timeDiffì\97\90 ì\93°ë\8b¤ ë§\8c 문서입니다.',
-    'pages_draft_edited_notification' => 'ìµ\9cê·¼ì\97\90 ì\88\98ì \95í\95\9c ë¬¸ì\84\9cì\9d´ê¸° ë\95\8c문ì\97\90 ì\93°ë\8b¤ ë§\8c 문서를 폐기하는 편이 좋습니다.',
+    'pages_editing_draft_notification' => ':timeDiffì\97\90 ì´\88ì\95\88 문서입니다.',
+    'pages_draft_edited_notification' => 'ìµ\9cê·¼ì\97\90 ì\88\98ì \95í\95\9c ë¬¸ì\84\9cì\9d´ê¸° ë\95\8c문ì\97\90 ì´\88ì\95\88 문서를 폐기하는 편이 좋습니다.',
     'pages_draft_edit_active' => [
         'start_a' => ':count명이 이 문서를 수정하고 있습니다.',
         'start_b' => ':userName이 이 문서를 수정하고 있습니다.',
@@ -232,7 +241,7 @@ return [
         'time_b' => '(:minCount분 전)',
         'message' => ':start :time. 다른 사용자의 수정본을 덮어쓰지 않도록 주의하세요.',
     ],
-    'pages_draft_discarded' => 'ì\93°ë\8b¤ ë§\8c 문서를 지웠습니다. 에디터에 현재 판본이 나타납니다.',
+    'pages_draft_discarded' => 'ì´\88ì\95\88 문서를 지웠습니다. 에디터에 현재 판본이 나타납니다.',
     'pages_specific' => '특정한 문서',
     'pages_is_template' => '템플릿',
 
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => '파일 올리기',
     'attachments_link' => '링크로 첨부',
     'attachments_set_link' => '링크 설정',
-    'attachments_delete_confirm' => '삭제하려면 버튼을 한 번 더 클릭하세요.',
+    'attachments_delete' => '이 첨부파일을 정말 삭제하시겠습니까?',
     'attachments_dropzone' => '여기에 파일을 드롭하거나 여기를 클릭하세요.',
     'attachments_no_files' => '올린 파일 없음',
     'attachments_explain_link' => '파일을 올리지 않고 링크로 첨부할 수 있습니다.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => '파일로 링크',
     'attachments_link_url_hint' => '파일 주소',
     'attach' => '파일 첨부',
+    'attachments_insert_link' => '페이지에 첨부파일 링크 추가',
     'attachments_edit_file' => '파일 수정',
     'attachments_edit_file_name' => '파일 이름',
     'attachments_edit_drop_upload' => '여기에 파일을 드롭하거나 여기를 클릭하세요. 파일을 올리거나 덮어쓸 수 있습니다.',
@@ -311,4 +321,4 @@ return [
     'revision_restore_confirm' => '이 수정본을 되돌릴 건가요? 현재 판본을 바꿉니다.',
     'revision_delete_success' => '수정본 지움',
     'revision_cannot_delete_latest' => '현재 판본은 지울 수 없습니다.'
-];
\ No newline at end of file
+];
index a9e917e912aa453fa85e08ec3b3a8f1baa6af7c6..b2a2c7a3a130214d2214e2797ead06d8abeec28a 100644 (file)
@@ -13,7 +13,7 @@ return [
     'email_already_confirmed' => '확인이 끝난 메일 주소입니다. 로그인하세요.',
     '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' => 'PHP에 LDAP 확장 도구를 설치하세요.',
@@ -46,11 +46,10 @@ return [
     'file_upload_timeout' => '파일을 올리는 데 걸리는 시간이 서버에서 허용하는 수치를 넘습니다.',
 
     // Attachments
-    'attachment_page_mismatch' => '올리는 위치와 현재 문서가 다릅니다.',
     'attachment_not_found' => '첨부 파일이 없습니다.',
 
     // Pages
-    'page_draft_autosave_fail' => 'ì\93°ë\8b¤ ë§\8c 문서를 유실했습니다. 인터넷 연결 상태를 확인하세요.',
+    'page_draft_autosave_fail' => 'ì´\88ì\95\88 문서를 유실했습니다. 인터넷 연결 상태를 확인하세요.',
     'page_custom_home_deletion' => '처음 페이지는 지울 수 없습니다.',
 
     // Entities
@@ -61,7 +60,7 @@ return [
     'chapter_not_found' => '챕터가 없습니다.',
     'selected_book_not_found' => '고른 책자가 없습니다.',
     'selected_book_chapter_not_found' => '고른 책자나 챕터가 없습니다.',
-    'guests_cannot_save_drafts' => 'Guestë\8a\94 ì\93°ë\8b¤ ë§\8c 문서를 보관할 수 없습니다.',
+    'guests_cannot_save_drafts' => 'Guestë\8a\94 ì´\88ì\95\88 문서를 보관할 수 없습니다.',
 
     // Users
     'users_cannot_delete_only_admin' => 'Admin을 삭제할 수 없습니다.',
@@ -75,7 +74,7 @@ return [
 
     // Comments
     'comment_list' => '댓글을 가져오다 문제가 생겼습니다.',
-    'cannot_add_comment_to_draft' => 'ì\93°ë\8b¤ ë§\8c 문서에 댓글을 달 수 없습니다.',
+    'cannot_add_comment_to_draft' => 'ì´\88ì\95\88 문서에 댓글을 달 수 없습니다.',
     'comment_add' => '댓글을 등록하다 문제가 생겼습니다.',
     'comment_delete' => '댓글을 지우다 문제가 생겼습니다.',
     'empty_comment' => '빈 댓글은 등록할 수 없습니다.',
@@ -83,21 +82,24 @@ return [
     // Error pages
     '404_page_not_found' => '404 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',
+    '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에 문제가 있는 것 같습니다',
     '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 01633977136f940cab59a6e492ba221ddb8f4032..f93902aefd42bb257a14df002c8d989faae5e071 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => '여덟 글자를 넘어야 합니다.',
     'user' => "메일 주소를 가진 사용자가 없습니다.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => '비밀번호 재설정 토큰이 이 이메일 주소에 유효하지 않습니다.',
     'sent' => '메일을 보냈습니다.',
     'reset' => '비밀번호를 바꿨습니다.',
 
index e7cec851a253bb1242429d13b93ba13cc6c509b2..6c81bc7355ef1c423ba822b0e00e72bdb4400211 100755 (executable)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => '처음 페이지',
     'app_homepage_desc' => '고른 페이지에 설정한 권한은 무시합니다.',
     'app_homepage_select' => '문서 고르기',
+    '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' => '댓글 사용 안 함',
     'app_disable_comments_toggle' => '댓글 사용 안 함',
     'app_disable_comments_desc' => '모든 페이지에서 댓글을 숨깁니다.',
@@ -48,7 +53,7 @@ return [
     'book_color' => '책 색상',
     'chapter_color' => '챕터 색상',
     'page_color' => '페이지 색상',
-    'page_draft_color' => '드래프트 페이지 색상',
+    'page_draft_color' => '초안 페이지 색상',
 
     // Registration Settings
     'reg_settings' => '가입',
@@ -56,7 +61,7 @@ return [
     'reg_enable_toggle' => '사이트 가입 허용',
     'reg_enable_desc' => '가입한 사용자는 단일한 권한을 가집니다.',
     'reg_default_role' => '가입한 사용자의 기본 권한',
-    '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' => '외부 LDAP 또는 SAML 인증이 활성화되어 있는 동안에는 위의 옵션이 무시된다. 사용 중인 외부 시스템에 대해 인증이 성공하면, 존재하지 않는 회원에 대한 사용자 계정이 자동으로 생성된다.',
     'reg_email_confirmation' => '메일 주소 확인',
     'reg_email_confirmation_toggle' => '주소 확인 요구',
     'reg_confirm_email_desc' => '도메인 차단을 쓰고 있으면 메일 주소를 확인해야 하고, 이 설정은 무시합니다.',
@@ -68,7 +73,7 @@ return [
     'maint' => '데이터',
     'maint_image_cleanup' => '이미지 정리',
     'maint_image_cleanup_desc' => "중복한 이미지를 찾습니다. 실행하기 전에 이미지를 백업하세요.",
-    'maint_image_cleanup_ignore_revisions' => '수정본에 있는 이미지 제외',
+    '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개를 지울 건가요?',
     'maint_image_cleanup_success' => '이미지 :count개 삭제함',
@@ -80,13 +85,51 @@ return [
     'maint_send_test_email_mail_subject' => '테스트 메일',
     'maint_send_test_email_mail_greeting' => '이메일 전송이 성공하였습니다.',
     'maint_send_test_email_mail_text' => '축하합니다! 이 메일을 받음으로 이메일 설정이 정상적으로 되었음을 확인하였습니다.',
+    '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',
+
+    // 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_parent' => 'Parent',
+    '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_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.',
+
+    // Audit Log
+    'audit' => '감사 기록',
+    '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' => '이벤트 필터',
+    '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_ip' => 'IP Address',
+    'audit_table_date' => '활동 날짜',
+    'audit_date_from' => '날짜 범위 시작',
+    'audit_date_to' => '날짜 범위 끝',
 
     // Role Settings
     'roles' => '권한',
     'role_user_roles' => '사용자 권한',
     'role_create' => '권한 만들기',
     'role_create_success' => '권한 만듦',
-    'role_delete' => 'ê¶\8cí\95\9c ì§\80ì\9a°ê¸°',
+    'role_delete' => 'ê¶\8cí\95\9c ì \9cê±°',
     'role_delete_confirm' => ':roleName(을)를 지웁니다.',
     'role_delete_users_assigned' => '이 권한을 가진 사용자 :userCount명에 할당할 권한을 고르세요.',
     'role_delete_no_migration' => "할당하지 않음",
@@ -96,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' => '사용자 관리',
@@ -105,7 +149,9 @@ 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' => '책자, 챕터, 문서별 권한은 이 설정에 우선합니다.',
     'role_asset_admins' => 'Admin 권한은 어디든 접근할 수 있지만 이 설정은 사용자 인터페이스에서 해당 활동을 표시할지 결정합니다.',
     'role_all' => '모든 항목',
@@ -121,24 +167,28 @@ return [
     'user_profile' => '사용자 프로필',
     'users_add_new' => '사용자 만들기',
     'users_search' => '사용자 검색',
+    'users_latest_activity' => 'Latest Activity',
     'users_details' => '사용자 정보',
     'users_details_desc' => '메일 주소로 로그인합니다.',
     'users_details_desc_no_email' => '사용자 이름을 바꿉니다.',
     'users_role' => '사용자 권한',
     'users_role_desc' => '고른 권한 모두를 적용합니다.',
-    'users_password' => '비밀번호',
+    'users_password' => '사용자 비밀번호',
     'users_password_desc' => '여섯 글자를 넘어야 합니다.',
     'users_send_invite_text' => '비밀번호 설정을 권유하는 메일을 보내거나 내가 정할 수 있습니다.',
     'users_send_invite_option' => '메일 보내기',
     'users_external_auth_id' => 'LDAP 확인',
-    '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_delete' => '사용자 삭제',
     'users_delete_named' => ':userName 삭제',
     'users_delete_warning' => ':userName에 관한 데이터를 지웁니다.',
     'users_delete_confirm' => '이 사용자를 지울 건가요?',
-    'users_delete_success' => '사용자 삭제함',
+    '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_edit_profile' => '프로필 바꾸기',
     'users_edit_success' => '프로필 바꿈',
@@ -153,31 +203,35 @@ return [
     'users_social_connected' => ':socialAccount(와)과 연결했습니다.',
     'users_social_disconnected' => ':socialAccount(와)과의 연결을 끊었습니다.',
     'users_api_tokens' => 'API 토큰',
-    'users_api_tokens_none' => 'No API tokens have been created for this user',
+    'users_api_tokens_none' => '이 사용자를 위해 생성된 API 토큰이 없습니다.',
     '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 토큰 만들기',
     'user_api_token_name' => '제목',
-    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
+    'user_api_token_name_desc' => '토큰이 의도한 목적을 향후에 상기시키기 위해 알아보기 쉬운 이름을 지정한다.',
     'user_api_token_expiry' => '만료일',
-    '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' => '이 토큰이 만료되는 날짜를 설정한다. 이 날짜가 지나면 이 토큰을 사용하여 만든 요청은 더 이상 작동하지 않는다. 이 칸을 공백으로 두면 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' => '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_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' => '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_delete_warning' => '이렇게 하면 시스템에서 \':tokenName\'이라는 이름을 가진 이 API 토큰이 완전히 삭제된다.',
+    'user_api_token_delete_confirm' => '이 API 토큰을 삭제하시겠습니까?',
+    'user_api_token_delete_success' => 'API 토큰이 성공적으로 삭제되었다.',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -192,13 +249,19 @@ return [
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
         'fr' => 'Français',
-        'he' => 'עברית',
+        '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',
index 180faa35f377afa18958033edcdbf51416124f9a..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(으)로 구성하세요.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute(을)를 적어도 :value바이트로 구성하세요.',
         'array'   => ':attribute(을)를 적어도 :value개로 구성하세요..',
     ],
-    'no_double_extension'  => ':attribute(이)가 단일한 확장자를 가져야 합니다.',
     'not_in'               => '고른 :attribute(이)가 유효하지 않습니다.',
     'not_regex'            => ':attribute(은)는 유효하지 않은 형식입니다.',
     'numeric'              => ':attribute(을)를 숫자로만 구성하세요.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':values(이)가 없을 때 :attribute(을)를 구성해야 합니다.',
     'required_without_all' => ':values(이)가 모두 없을 때 :attribute(을)를 구성해야 합니다.',
     'same'                 => ':attribute(와)과 :other(을)를 똑같이 구성하세요.',
+    'safe_url'             => 'The provided link may not be safe.',
     'size'                 => [
         'numeric' => ':attribute(을)를 :size(으)로 구성하세요.',
         'file'    => ':attribute(을)를 :size킬로바이트로 구성하세요.',
@@ -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..b128934
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'sukurtas puslapis',
+    'page_create_notification'    => 'Puslapis sukurtas sėkmingai',
+    'page_update'                 => 'atnaujintas puslapis',
+    'page_update_notification'    => 'Puslapis sėkmingai atnaujintas',
+    'page_delete'                 => 'ištrintas puslapis',
+    'page_delete_notification'    => 'Puslapis sėkmingai ištrintas',
+    'page_restore'                => 'atkurtas puslapis',
+    'page_restore_notification'   => 'Puslapis sėkmingai atkurtas',
+    'page_move'                   => 'perkeltas puslapis',
+
+    // Chapters
+    'chapter_create'              => 'sukurtas skyrius',
+    'chapter_create_notification' => 'Skyrius sėkmingai sukurtas',
+    'chapter_update'              => 'atnaujintas skyrius',
+    'chapter_update_notification' => 'Skyrius sekmingai atnaujintas',
+    'chapter_delete'              => 'ištrintas skyrius',
+    'chapter_delete_notification' => 'Skyrius sėkmingai ištrintas',
+    'chapter_move'                => 'perkeltas skyrius',
+
+    // Books
+    'book_create'                 => 'sukurta knyga',
+    'book_create_notification'    => 'Knyga sėkmingai sukurta',
+    'book_update'                 => 'atnaujinta knyga',
+    'book_update_notification'    => 'Knyga sėkmingai atnaujinta',
+    'book_delete'                 => 'ištrinta knyga',
+    'book_delete_notification'    => 'Knyga sėkmingai ištrinta',
+    'book_sort'                   => 'surūšiuota knyga',
+    'book_sort_notification'      => 'Knyga sėkmingai perrūšiuota',
+
+    // Bookshelves
+    'bookshelf_create'            => 'sukurta knygų lentyna',
+    'bookshelf_create_notification'    => 'Knygų lentyna sėkmingai sukurta',
+    'bookshelf_update'                 => 'atnaujinta knygų lentyna',
+    'bookshelf_update_notification'    => 'Knygų lentyna sėkmingai atnaujinta',
+    'bookshelf_delete'                 => 'ištrinta knygų lentyna',
+    'bookshelf_delete_notification'    => 'Knygų lentyna sėkmingai ištrinta',
+
+    // 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',
+
+    // 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..f9bf271
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Privalo būti daugiau nei 7 simboliai',
+    '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' => 'Jūsų elektroninis paštas buvo patvirtintas!',
+    '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' => 'Slaptažodis nustatytas, dabar turite prieigą prie :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.',
+];
\ No newline at end of file
diff --git a/resources/lang/lt/common.php b/resources/lang/lt/common.php
new file mode 100644 (file)
index 0000000..7ed434b
--- /dev/null
@@ -0,0 +1,95 @@
+<?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',
+
+    // 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',
+
+    // 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..2b9a5e2
--- /dev/null
@@ -0,0 +1,324 @@
+<?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' => 'My Favourites',
+    '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ą',
+
+    // 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_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_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' =>  'Tags',
+    'tag_name' =>  'Tag Name',
+    '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ą',
+    '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'
+];
diff --git a/resources/lang/lt/errors.php b/resources/lang/lt/errors.php
new file mode 100644 (file)
index 0000000..c16c37a
--- /dev/null
@@ -0,0 +1,105 @@
+<?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.',
+    '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..f5795ed
--- /dev/null
@@ -0,0 +1,277 @@
+<?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' => 'Susikurkite slaptažodį, kuris bus naudojamas prisijungti prie aplikacijos. Slaptažodis turi būti bent 6 simbolių ilgio.',
+    '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',
+
+    //! 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',
+        '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' => [],
+];
diff --git a/resources/lang/lv/activities.php b/resources/lang/lv/activities.php
new file mode 100644 (file)
index 0000000..fc2d876
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'izveidoja lapu',
+    'page_create_notification'    => 'Lapa Veiksmīgi Izveidota',
+    'page_update'                 => 'atjaunoja lapu',
+    'page_update_notification'    => 'Lapa Veiksmīgi Atjaunota',
+    'page_delete'                 => 'izdzēsa lapu',
+    'page_delete_notification'    => 'Lapa Veiksmīgi Dzēsta',
+    'page_restore'                => 'atjaunoja lapu',
+    '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_update'              => 'atjaunoja nodaļu',
+    'chapter_update_notification' => 'Nodaļa Veiksmīgi Atjaunota',
+    'chapter_delete'              => 'izdzēsa nodaļu',
+    '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_update'                 => 'atjaunoja grāmatu',
+    'book_update_notification'    => 'Grāmata Veiksmīgi Atjaunota',
+    'book_delete'                 => 'izdzēsa grāmatu',
+    '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',
+
+    // Bookshelves
+    'bookshelf_create'            => 'izveidoja Plauktu',
+    'bookshelf_create_notification'    => 'Plaukts Veiksmīgi Izveidots',
+    'bookshelf_update'                 => 'atjaunoja plauktu',
+    'bookshelf_update_notification'    => 'Plaukts Veiksmīgi Atjaunots',
+    'bookshelf_delete'                 => 'izdzēsa plauktu',
+    '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',
+
+    // Other
+    'commented_on'                => 'komentēts',
+    'permissions_update'          => 'atjaunoja atļaujas',
+];
diff --git a/resources/lang/lv/auth.php b/resources/lang/lv/auth.php
new file mode 100644 (file)
index 0000000..497509d
--- /dev/null
@@ -0,0 +1,112 @@
+<?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 reģistrācijas dati neatbilst mūsu ierakstiem.',
+    'throttle' => 'Pārāk daudz pieteikšanās mēģinājumu. Lūdzu, mēģiniet vēlreiz pēc :seconds seconds.',
+
+    // Login & Register
+    'sign_up' => 'Reģistrēties',
+    'log_in' => 'Ielogoties',
+    'log_in_with' => 'Ielogoties ar :socialDriver',
+    'sign_up_with' => 'Pieteikties ar :socialDriver',
+    'logout' => 'Iziet',
+
+    'name' => 'Vārds',
+    'username' => 'Lietotājvārds',
+    'email' => 'E-pasts',
+    'password' => 'Parole',
+    'password_confirm' => 'Apstiprināt paroli',
+    'password_hint' => 'Jābūt vismaz 8 rakstzīmēm',
+    'forgot_password' => 'Aizmirsta parole?',
+    'remember_me' => 'Atcerēties mani',
+    'ldap_email_hint' => 'Lūdzu ievadiet e-pastu, kuru izmantosiet šim profilam.',
+    'create_account' => 'Izveidot profilu',
+    'already_have_account' => 'Jau ir profils?',
+    'dont_have_account' => 'Nav profila?',
+    'social_login' => 'Pieteikšanās ar sociālo tīklu profilu',
+    'social_registration' => 'Reģistrēšanās ar sociālo profilu',
+    'social_registration_text' => 'Reģistrēties vai pieteikties izmantojot citu servisu.',
+
+    'register_thanks' => 'Paldies par reģistrāciju!',
+    'register_confirm' => 'Lūdzu, pārbaudiet savu e-pastu un nospiediet apstiprināšanas pogu, lai piekļūtu :appName.',
+    'registrations_disabled' => 'Reģistrācija ir izslēgta',
+    '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.',
+    'reset_password_send_button' => 'Nosūtīt atiestatīšanas saiti',
+    'reset_password_sent' => 'Paroles atiestatīšanas saite tiks nosūtīta uz :email, ja šāds e-pasts būs derīgs.',
+    'reset_password_success' => 'Jūsu parole ir veiksmīgi atiestatīta.',
+    'email_reset_subject' => 'Atiestatīt :appName paroli',
+    '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_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',
+    'email_not_confirmed_text' => 'Jūsu e-pasta adrese vēl nav apstiprināta.',
+    'email_not_confirmed_click_link' => 'Lūdzu, noklikšķiniet uz saiti nosūtītajā e-pastā pēc reģistrēšanās.',
+    'email_not_confirmed_resend' => 'Ja neredzi e-pastu, tad vari atkārtoti nosūtīt apstiprinājuma e-pastu iesniedzot zemāk redzamo formu.',
+    'email_not_confirmed_resend_button' => 'Atkārtoti nosūtīt apstiprinājuma e-pastu',
+
+    // User Invite
+    'user_invite_email_subject' => 'Tu esi uzaicināts pievienoties :appName!',
+    'user_invite_email_greeting' => 'Jūsu :appName profils ir izveidots.',
+    'user_invite_email_text' => 'Lūdzu, nospiediet zemāk redzamo pogu, lai izveidotu paroli un iegūtu piekļuvi:',
+    'user_invite_email_action' => 'Iestatīt profila paroli',
+    '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!',
+
+    // 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' => '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.',
+];
\ No newline at end of file
diff --git a/resources/lang/lv/common.php b/resources/lang/lv/common.php
new file mode 100644 (file)
index 0000000..23cd07d
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Atcelt',
+    'confirm' => 'Apstiprināt',
+    'back' => 'Atpakaļ',
+    'save' => 'Saglabāt',
+    'continue' => 'Turpināt',
+    'select' => 'Atlasīt',
+    'toggle_all' => 'Iezīmēt visus',
+    'more' => 'Vairāk',
+
+    // Form Labels
+    'name' => 'Nosaukums',
+    'description' => 'Apraksts',
+    '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',
+    'view_all' => 'Skatīt visus',
+    'create' => 'Izveidot',
+    'update' => 'Atjaunināt',
+    'edit' => 'Rediģēt',
+    'sort' => 'Kārtot',
+    'move' => 'Pārvietot',
+    'copy' => 'Kopēt',
+    'reply' => 'Atbildēt',
+    'delete' => 'Dzēst',
+    'delete_confirm' => 'Apstipriniet dzēšanu',
+    'search' => 'Meklēt',
+    'search_clear' => 'Notīrīt meklēšanu',
+    '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',
+
+    // Sort Options
+    'sort_options' => 'Kārtošanas Opcijas',
+    'sort_direction_toggle' => 'Pārslēgt kārtošanas virzienu',
+    'sort_ascending' => 'Kārtot Augoši',
+    'sort_descending' => 'Kārtot Dilstoši',
+    'sort_name' => 'Vārds',
+    'sort_default' => 'Noklusējums',
+    'sort_created_at' => 'Izveidošanas Datums',
+    'sort_updated_at' => 'Atjaunināšanas datums',
+
+    // Misc
+    'deleted_user' => 'Dzēsts lietotājs',
+    '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',
+    'grid_view' => 'Režģa Skats',
+    'list_view' => 'Saraksta Skats',
+    'default' => 'Noklusējums',
+    'breadcrumb' => 'Navigācija',
+
+    // Header
+    'header_menu_expand' => 'Izvērst galvenes izvēlni',
+    'profile_menu' => 'Profila izvēlne',
+    'view_profile' => 'Apskatīt profilu',
+    'edit_profile' => 'Rediģēt profilu',
+    'dark_mode' => 'Tumšais režīms',
+    'light_mode' => 'Gaišais režīms',
+
+    // Layout tabs
+    'tab_info' => 'Informācija',
+    'tab_info_label' => 'Tab: Rādīt sekundāro informāciju',
+    'tab_content' => 'Saturs',
+    'tab_content_label' => 'Tab: Rādīt galveno saturu',
+
+    // Email Content
+    'email_action_help' => 'Ja ir problēmas noklikšķināt ":actionText" pogu, nokopē un ievieto saiti savā interneta pārlūkā:',
+    'email_rights' => 'Visas tiesības aizsargātas',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privātuma politika',
+    'terms_of_service' => 'Pakalpojuma noteikumi',
+];
diff --git a/resources/lang/lv/components.php b/resources/lang/lv/components.php
new file mode 100644 (file)
index 0000000..b66461d
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Attēla izvēle',
+    'image_all' => 'Visi',
+    'image_all_title' => 'Skatīt visus attēlus',
+    'image_book_title' => 'Apskatīt augšupielādētos attēlus šajā grāmatā',
+    'image_page_title' => 'Apskatīt augšupielādētos attēlus šajā lapā',
+    'image_search_hint' => 'Meklēt pēc attēla vārda',
+    'image_uploaded' => 'Augšupielādēts :uploadedDate',
+    'image_load_more' => 'Ielādēt vairāk',
+    'image_image_name' => 'Attēla nosaukums',
+    'image_delete_used' => 'Šis attēls ir ievietots zemāk redzamajās lapās.',
+    'image_delete_confirm_text' => 'Vai tiešām vēlaties dzēst šo attēlu?',
+    'image_select_image' => 'Atlasīt attēlu',
+    'image_dropzone' => 'Ievilkt attēlu vai klikšķinat šeit, lai augšupielādētu',
+    'images_deleted' => 'Dzēstie attēli',
+    'image_preview' => 'Attēla priekšskatījums',
+    'image_upload_success' => 'Attēls ir veiksmīgi augšupielādēts',
+    'image_update_success' => 'Attēlā informācija ir veiksmīgi atjunināta',
+    'image_delete_success' => 'Attēls veiksmīgi dzēsts',
+    'image_upload_remove' => 'Noņemt',
+
+    // Code Editor
+    'code_editor' => 'Rediģēt kodu',
+    'code_language' => 'Koda valoda',
+    'code_content' => 'Koda teksts',
+    'code_session_history' => 'Sesijas vēsture',
+    'code_save' => 'Saglabāt kodu',
+];
diff --git a/resources/lang/lv/entities.php b/resources/lang/lv/entities.php
new file mode 100644 (file)
index 0000000..a28829f
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Nesen izveidots',
+    'recently_created_pages' => 'Nesen izveidotās lapas',
+    'recently_updated_pages' => 'Nesen atjauninātās lapas',
+    'recently_created_chapters' => 'Nesen izveidotās nodaļas',
+    'recently_created_books' => 'Nesen izveidotās grāmatas',
+    'recently_created_shelves' => 'Nesen izveidotie plaukti',
+    'recently_update' => 'Nesen atjaunināts',
+    'recently_viewed' => 'Nesen skatītie',
+    'recent_activity' => 'Pēdējās aktivitātes',
+    'create_now' => 'Izveidot tagad',
+    'revisions' => 'Revīzijas',
+    'meta_revision' => 'Revīzija #:revisionCount',
+    'meta_created' => 'Izveidots :timeLength',
+    'meta_created_name' => ':user izveidojis pirms :timeLength',
+    'meta_updated' => 'Atjaunināts :timeLength',
+    'meta_updated_name' => ':user atjauninājis pirms :timeLength',
+    'meta_owned_name' => 'Īpašnieks :user',
+    'entity_select' => 'Izvēlēties vienumu',
+    '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',
+    'export' => 'Eksportēt',
+    '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',
+    'permissions_intro' => 'Kolīdz ieslēgtas, šīs atļaujas ņems prioritāti pār jebkurām citām uzstādītajām atļaujām.',
+    'permissions_enable' => 'Ieslēgt pielāgotās atļaujas',
+    'permissions_save' => 'Saglabāt atļaujas',
+    'permissions_owner' => 'Īpašnieks',
+
+    // Search
+    'search_results' => 'Meklēšanas rezultāti',
+    'search_total_results_found' => ':count meklēšanas rezultāts|:count meklēšanas rezultāti',
+    'search_clear' => 'Notīrīt meklēšanu',
+    'search_no_pages' => 'Neviena lapa neatbilst meklēšanai',
+    'search_for_term' => 'Meklēt :term',
+    'search_more' => 'Vairāk rezultāti',
+    'search_advanced' => 'Paplašināta meklēšana',
+    'search_terms' => 'Meklēšanas parametri',
+    'search_content_type' => 'Satura tips',
+    'search_exact_matches' => 'Precīza atbilstība',
+    'search_tags' => 'Birku meklēšana',
+    'search_options' => 'Iestatījumi',
+    'search_viewed_by_me' => 'Manis apskatītie',
+    'search_not_viewed_by_me' => 'Neesmu skatījis',
+    'search_permissions_set' => 'Iestatītās atļaujas',
+    'search_created_by_me' => 'Manis izveidotie',
+    'search_updated_by_me' => 'Manis atjauninātie',
+    'search_owned_by_me' => 'Es esmu īpašnieks',
+    'search_date_options' => 'Datuma iestatījumi',
+    'search_updated_before' => 'Atjaunināts pirms',
+    'search_updated_after' => 'Atjaunināts pēc',
+    'search_created_before' => 'Izveidots pirms',
+    'search_created_after' => 'Izveidots pēc',
+    'search_set_date' => 'Norādīt datumu',
+    'search_update' => 'Atjaunināt meklētāju',
+
+    // Shelves
+    'shelf' => 'Plaukts',
+    'shelves' => 'Plaukti',
+    'x_shelves' => ':count Plaukts|:count Plaukti',
+    'shelves_long' => 'Grāmatu plautki',
+    'shelves_empty' => 'Neviens plaukts nav izveidots',
+    'shelves_create' => 'Izveidot jaunu plauktu',
+    'shelves_popular' => 'Populāri plaukti',
+    'shelves_new' => 'Jauni plaukti',
+    'shelves_new_action' => 'Jauns plaukts',
+    'shelves_popular_empty' => 'Populārākie plaukti tiks rādīti šeit.',
+    'shelves_new_empty' => 'Pēdējie izveidotie plaukti tiks rādīti šeit.',
+    'shelves_save' => 'Saglabāt plauktu',
+    'shelves_books' => 'Grāmatas šajā plauktā',
+    'shelves_add_books' => 'Pievienot grāmatas šim plauktam',
+    'shelves_drag_books' => 'Ievelciet grāmatas šeit, lai novietotu tās šajā plauktā',
+    'shelves_empty_contents' => 'Šim gŗamatplauktam nav pievienotu grāmatu',
+    'shelves_edit_and_assign' => 'Labot plauktu, lai tam pievienotu grāmatas',
+    'shelves_edit_named' => 'Labot grāmatplauktu :name',
+    'shelves_edit' => 'Labot grāmatplauktu',
+    'shelves_delete' => 'Dzēst grāmatplauktu',
+    'shelves_delete_named' => 'Dzēst grāmatplauktu :name',
+    'shelves_delete_explain' => "Tiks dzēsts grāmatplaukts ar nosaukumu \":name\". Tajā ievietotās grāmatas netiks dzēstas.",
+    'shelves_delete_confirmation' => 'Vai esat pārliecināts, ka vēlaties dzēst šo grāmatplauktu?',
+    '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.',
+    'shelves_copy_permission_success' => 'Grāmatplaukta atļaujas ir pārkopētas uz :count grāmatām',
+
+    // Books
+    'book' => 'Grāmata',
+    'books' => 'Grāmatas',
+    'x_books' => ':count grāmata|:count grāmatas',
+    'books_empty' => 'Neviena grāmata nav izveidota',
+    'books_popular' => 'Populārās grāmatas',
+    'books_recent' => 'Nesenās grāmatas',
+    'books_new' => 'Jaunas grāmatas',
+    'books_new_action' => 'Jauna grāmata',
+    'books_popular_empty' => 'Populārākās grāmatas tiks rādītas šeit.',
+    'books_new_empty' => 'Pēdējās izveidotās grāmatas tiks rādītas šeit.',
+    'books_create' => 'Izveidot jaunu grāmatu',
+    'books_delete' => 'Dzēst grāmatu',
+    'books_delete_named' => 'Dzēst grāmatu :bookName',
+    'books_delete_explain' => 'Šī darbība izdzēsīs grāmatu \':bookName\'. Visas lapas un nodaļas tiks izdzēstas.',
+    'books_delete_confirmation' => 'Vai esat pārliecināts, ka vēlaties dzēst šo grāmatu?',
+    'books_edit' => 'Labot grāmatu',
+    'books_edit_named' => 'Labot grāmatu :bookName',
+    'books_form_book_name' => 'Grāmatas nosaukums',
+    'books_save' => 'Saglabāt grāmatu',
+    'books_permissions' => 'Grāmatas atļaujas',
+    'books_permissions_updated' => 'Grāmatas atļaujas atjauninātas',
+    'books_empty_contents' => 'Lapas vai nodaļas vēl nav izveidotas šai grāmatai.',
+    'books_empty_create_page' => 'Izveidot jaunu lapu',
+    'books_empty_sort_current_book' => 'Kārtot šo grāmatu',
+    'books_empty_add_chapter' => 'Pievienot nodaļu',
+    'books_permissions_active' => 'Grāmatas atļaujas ir aktīvas',
+    'books_search_this' => 'Meklēt šajā grāmatā',
+    'books_navigation' => 'Grāmatas navigācija',
+    'books_sort' => 'Kārtot grāmatas saturu',
+    'books_sort_named' => 'Kārtot grāmatu :bookName',
+    'books_sort_name' => 'Kārtot pēc nosaukuma',
+    'books_sort_created' => 'Kārtot pēc izveidošanas datuma',
+    'books_sort_updated' => 'Kārtot pēc atjaunināšanas datuma',
+    'books_sort_chapters_first' => 'Nodaļas pirmās',
+    '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',
+
+    // Chapters
+    'chapter' => 'Nodaļa',
+    'chapters' => 'Nodaļas',
+    'x_chapters' => ':count nodaļa|:count nodaļas',
+    'chapters_popular' => 'Populāras nodaļas',
+    'chapters_new' => 'Jauna nodaļa',
+    'chapters_create' => 'Izveidot jaunu nodaļu',
+    'chapters_delete' => 'Dzēst nodaļu',
+    'chapters_delete_named' => 'Dzēst nodaļu :chapterName',
+    'chapters_delete_explain' => 'Šī darbība dzēsīs nodaļu \':chapterName\'. Visas tajā esošās lapas arī tiks dzēstas.',
+    'chapters_delete_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo nodaļu?',
+    'chapters_edit' => 'Labot nodaļu',
+    'chapters_edit_named' => 'Labot nodaļu :chapterName',
+    'chapters_save' => 'Saglabāt nodaļu',
+    'chapters_move' => 'Pārvietot nodaļu',
+    'chapters_move_named' => 'Pārvietot nodaļu :chapterName',
+    'chapter_move_success' => 'Nodaļa pārviedota uz :bookName',
+    'chapters_permissions' => 'Nodaļas atļaujas',
+    'chapters_empty' => 'Šajā nodaļā nav pievienotu lapu.',
+    'chapters_permissions_active' => 'Nodaļas atļaujas ir aktīvas',
+    'chapters_permissions_success' => 'Nodaļas atļaujas ir atjauninātas',
+    'chapters_search_this' => 'Meklēt šajā nodaļā',
+
+    // Pages
+    'page' => 'Lapa',
+    'pages' => 'Lapas',
+    'x_pages' => ':count lapa|:count lapas',
+    'pages_popular' => 'Populātas lapas',
+    'pages_new' => 'Jauna lapa',
+    'pages_attachments' => 'Pielikumi',
+    'pages_navigation' => 'Lapas navigācija',
+    'pages_delete' => 'Dzēst lapu',
+    'pages_delete_named' => 'Dzēst lapu :pageName',
+    'pages_delete_draft_named' => 'Dzēst :pageName melnrakstu',
+    'pages_delete_draft' => 'Dzēst melnrakstu',
+    'pages_delete_success' => 'Lapa ir dzēsta',
+    'pages_delete_draft_success' => 'Melnraksts ir dzēsts',
+    'pages_delete_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo lapu?',
+    'pages_delete_draft_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo melnrakstu?',
+    'pages_editing_named' => 'Rediģē lapu :pageName',
+    'pages_edit_draft_options' => 'Melnraksta iestatījumi',
+    'pages_edit_save_draft' => 'Saglabāt melnrakstu',
+    'pages_edit_draft' => 'Labot melnrakstu',
+    'pages_editing_draft' => 'Labo melnrakstu',
+    'pages_editing_page' => 'Labo lapu',
+    'pages_edit_draft_save_at' => 'Melnraksts saglabāts ',
+    'pages_edit_delete_draft' => 'Dzēst melnrakstu',
+    'pages_edit_discard_draft' => 'Atmest malnrakstu',
+    'pages_edit_set_changelog' => 'Pievienot izmaiņu aprakstu',
+    'pages_edit_enter_changelog_desc' => 'Ievadi nelielu aprakstu par vaiktajām izmaiņām',
+    'pages_edit_enter_changelog' => 'Izmaiņu apraksts',
+    'pages_save' => 'Saglabāt lapu',
+    'pages_title' => 'Lapas virsraksts',
+    'pages_name' => 'Lapas nosaukums',
+    'pages_md_editor' => 'Redaktors',
+    'pages_md_preview' => 'Priekšskatījums',
+    'pages_md_insert_image' => 'Ievietot attēlu',
+    'pages_md_insert_link' => 'Ievietot vienuma saiti',
+    'pages_md_insert_drawing' => 'Ievietot zīmējumu',
+    'pages_not_in_chapter' => 'Lapa nav nodaļā',
+    'pages_move' => 'Pārvietot lapu',
+    'pages_move_success' => 'Lapa pārvietota uz ":parentName"',
+    'pages_copy' => 'Kopēt lapu',
+    'pages_copy_desination' => 'Kopijas mērķa vieta',
+    'pages_copy_success' => 'Lapa veiksmīgi nokopēta',
+    'pages_permissions' => 'Lapas atļaujas',
+    'pages_permissions_success' => 'Lapas atļaujas atjauninātas',
+    'pages_revision' => 'Revīzijas',
+    'pages_revisions' => 'Lapas revīzijas',
+    'pages_revisions_named' => ':pageName lapas revīzijas',
+    'pages_revision_named' => ':pageName lapas revīzija',
+    'pages_revision_restored_from' => 'Atjaunots no #:id; :summary',
+    'pages_revisions_created_by' => 'Izveidoja',
+    'pages_revisions_date' => 'Revīzijas datums',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Revīzija #:id',
+    'pages_revisions_numbered_changes' => 'Revīzijas #:id izmaiņas',
+    'pages_revisions_changelog' => 'Izmaiņu žurnāls',
+    'pages_revisions_changes' => 'Izmaiņas',
+    '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',
+    'pages_copy_link' => 'Kopēt saiti',
+    'pages_edit_content_link' => 'Labot saturu',
+    'pages_permissions_active' => 'Lapas atļaujas ir aktīvas',
+    'pages_initial_revision' => 'Sākotnējā publikācija',
+    '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_edit_active' => [
+        'start_a' => ':count lietotāji pašlaik veic izmaiņas šajā lapā',
+        'start_b' => ':userName veic izmaiņas šajā lapā',
+        'time_a' => 'kopš šī lapa pēdējo reizi ir atjaunināta',
+        'time_b' => 'pēdējās :minCount minūtēs',
+        'message' => ':start :time. Esat uzmanīgi, lai neaizstātu viens otra izmaiņas!',
+    ],
+    'pages_draft_discarded' => 'Melnraksts ir atcelts, redaktors ir atjaunināts ar pašreizējo lapas saturu',
+    'pages_specific' => 'Konkrēta lapa',
+    'pages_is_template' => 'Lapas šablons',
+
+    // Editor Sidebar
+    'page_tags' => 'Lapas birkas',
+    'chapter_tags' => 'Nodaļas birkas',
+    'book_tags' => 'Grāmatas birkas',
+    'shelf_tags' => 'Plauktu birkas',
+    'tag' => 'Birka',
+    'tags' =>  'Birkas',
+    'tag_name' =>  'Birkas nosaukums',
+    'tag_value' => 'Birkas papildvērtība (neobligāta)',
+    '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',
+    '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.',
+    'attachments_items' => 'Pievienotie vienumi',
+    'attachments_upload' => 'Augšupielādēt failu',
+    'attachments_link' => 'Pievienot saiti',
+    'attachments_set_link' => 'Uzstādīt saiti',
+    'attachments_delete' => 'Vai tiešām vēlaties dzēst šo pielikumu?',
+    'attachments_dropzone' => 'Ievilkt failus vai klikšķināt šeit, lai pievieotu failus',
+    'attachments_no_files' => 'Neviens fails nav augšupielādēts',
+    'attachments_explain_link' => 'Ja nevēlaties augšupielādēt failu, varat pievienot saiti. Tā var būt saite uz citu lapu vai saite uz failu mākonī.',
+    'attachments_link_name' => 'Saites nosaukums',
+    'attachment_link' => 'Pielikuma saite',
+    'attachments_link_url' => 'Saite uz failu',
+    'attachments_link_url_hint' => 'Web lapas vai faila URL',
+    'attach' => 'Pievienot',
+    'attachments_insert_link' => 'Pievienot pielikuma saiti lapai',
+    'attachments_edit_file' => 'Rediģēt failu',
+    'attachments_edit_file_name' => 'Faila nosaukums',
+    'attachments_edit_drop_upload' => 'Ievelc failus vai spied šeit, lai augšupielādētu vai aizstātu failus',
+    'attachments_order_updated' => 'Pielikuma secība ir atjaunināta',
+    'attachments_updated_success' => 'Pielikuma informācja ir atjaunināta',
+    'attachments_deleted' => 'Pielikums dzēsts',
+    'attachments_file_uploaded' => 'Fails veiksmīgi augšupielādēts',
+    'attachments_file_updated' => 'Fails veiksmīgi atjaunināts',
+    'attachments_link_attached' => 'Hipersaite veismīgi pievienota lapai',
+    'templates' => 'Šabloni',
+    'templates_set_as_template' => 'Šī lapa ir šablons',
+    'templates_explain_set_as_template' => 'Jūs varat iestatīt šo lapu kā veidni, lai tās saturs tiktu izmantots, veidojot citas lapas. Citi lietotāji varēs izmantot šo veidni, ja viņiem būs atļauja piekļūt šai lapai.',
+    'templates_replace_content' => 'Aizstāt lapas saturu',
+    'templates_append_content' => 'Pievienot lapas saturam (beigās)',
+    'templates_prepend_content' => 'Pievienot lapas saturam (sākumā)',
+
+    // Profile View
+    'profile_user_for_x' => 'Lietotājs jau :time',
+    'profile_created_content' => 'Izveidotais saturs',
+    'profile_not_created_pages' => ':userName nav izveidojis lapas',
+    'profile_not_created_chapters' => ':userName nav izveidojis nodalas',
+    'profile_not_created_books' => ':userName nav izveidojis grāmatas',
+    'profile_not_created_shelves' => ':userName nav izveidojis grāmatplauktus',
+
+    // Comments
+    'comment' => 'Komentārs',
+    'comments' => 'Komentāri',
+    'comment_add' => 'Pievienot komentāru',
+    'comment_placeholder' => 'Pievieno komentāru',
+    'comment_count' => '{0} Nav komentāru |{1} 1 Komentārs|[2,*] :count Komentāri',
+    'comment_save' => 'Saglabāt komentāru',
+    'comment_saving' => 'Saglabā komentāru...',
+    'comment_deleting' => 'Dzēš komentāru...',
+    'comment_new' => 'Jauns komentārs',
+    'comment_created' => 'komentējis :createDiff',
+    'comment_updated' => ':username atjauninājis pirms :updateDiff',
+    'comment_deleted_success' => 'Komentārs ir dzēsts',
+    'comment_created_success' => 'Komentārs ir pievienots',
+    'comment_updated_success' => 'Komentārs ir atjaunināts',
+    'comment_delete_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo komentāru?',
+    'comment_in_reply_to' => 'Atbildēt uz :commentId',
+
+    // Revision
+    'revision_delete_confirm' => 'Vai esat pārliecināts, ka vēlaties dzēst šo revīziju?',
+    '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 pašreizējo revīziju.'
+];
diff --git a/resources/lang/lv/errors.php b/resources/lang/lv/errors.php
new file mode 100644 (file)
index 0000000..c1746d6
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Jums nav atļauts piekļūt šai lapai.',
+    'permissionJson' => 'Jums nav atļauts veikt konkrēto darbību.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Lietotājs ar epastu :email bet ar citiem piekļuves datiem jau eksistē.',
+    'email_already_confirmed' => 'Epasts jau ir apstiprināts, mēģini ielogoties.',
+    'email_confirmation_invalid' => 'Šis apstiprinājuma žetons nav derīgs vai jau ir izmantots. Lūdzu, mēģiniet reģistrēties vēlreiz.',
+    'email_confirmation_expired' => 'Apstiprinājuma žetona derīguma termiņš ir beidzies. Ir nosūtīts jauns apstiprinājuma e-pasts.',
+    'email_confirmation_awaiting' => 'Šī konta e-pasta adresei ir nepieciešms apstiprinājums',
+    'ldap_fail_anonymous' => 'LDAP piekļuve neveiksmīga izmantojot anonymous bind',
+    'ldap_fail_authed' => 'LDAP piekļuve neveiksmīga izmantojot norādīto dn un paroli',
+    'ldap_extension_not_installed' => 'LDAP PHP paplašinājums nav instalēts',
+    'ldap_cannot_connect' => 'Nav iespējams pieslēgties LDAP serverim, sākotnējais pieslēgums neveiksmīgs',
+    'saml_already_logged_in' => 'Jau ielogojies',
+    'saml_user_not_registered' => 'Lietotājs :name nav reģistrēts un automātiska reģistrācija ir izslēgta',
+    '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',
+    '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.',
+    'social_account_email_in_use' => 'Šis epasts :email jau tiek izmantots. Ja jums jau ir konts, jūs varat pieslēgt savu :socialAccount kontu savos profila uzstādījumos.',
+    'social_account_existing' => 'Šis :socialAccount konts jau ir piesaistīts jūsu profilam.',
+    'social_account_already_used_existing' => 'Šo :socialAccount konts jau ir piesaistīts citam lietotājam.',
+    'social_account_not_used' => 'Šis :socialAccount konts nav piesaistīts nevienam lietotājām. Lūdzu pievienojiet to savos profila uzstādījumos. ',
+    'social_account_register_instructions' => 'Ja jums vēl nav savs konts, jūs varat reģistrēt kontu izmantojot :socialAccount piekļuvi.',
+    'social_driver_not_found' => 'Sociālā tīkla savienojums nav atrasts',
+    'social_driver_not_configured' => 'Jūsu :socialAccount sociālie iestatījumi nav uzstādīti pareizi.',
+    'invite_token_expired' => 'Šī uzaicinājuma saite ir novecojusi. Tā vietā jūs varat mēģināt atiestatīt sava konta paroli.',
+
+    // System
+    'path_not_writable' => 'Faila ceļā :filePath nav iespējams ielādēt failus. Lūdzu pārliecinieties, ka serverim tur ir rakstīšanas tiesības.',
+    'cannot_get_image_from_url' => 'Nevar iegūt bildi no :url',
+    'cannot_create_thumbs' => 'Serveris nevar izveidot samazinātus attēlus. Lūdzu pārbaudiet, vai ir uzstādīts PHP GD paplašinājums.',
+    'server_upload_limit' => 'Serveris neatļauj šāda izmēra failu ielādi. Lūdzu mēģiniet mazāka izmēra failu.',
+    'uploaded'  => 'Serveris neatļauj šāda izmēra failu ielādi. Lūdzu mēģiniet mazāka izmēra failu.',
+    'image_upload_error' => 'Radās kļūda augšupielādējot attēlu',
+    'image_upload_type_error' => 'Ielādējamā attēla tips nav derīgs',
+    'file_upload_timeout' => 'Faila augšupielādē ir iestājies noilgums.',
+
+    // Attachments
+    'attachment_not_found' => 'Pielikums nav atrasts',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Neizdevās saglabāt uzmetumu. Pārliecinieties, ka jūsu interneta pieslēgums ir aktīvs pirms saglabājiet šo lapu',
+    'page_custom_home_deletion' => 'Nav iespējams izdzēst lapu kamēr tā ir uzstādīta kā sākumlapa',
+
+    // Entities
+    'entity_not_found' => 'Vienība nav atrasta',
+    'bookshelf_not_found' => 'Grāmatplaukts nav atrasts',
+    'book_not_found' => 'Grāmata nav atrasta',
+    'page_not_found' => 'Lapa nav atrasta',
+    'chapter_not_found' => 'Nodaļa nav atrasta',
+    'selected_book_not_found' => 'Iezīmētā grāmata nav atrasta',
+    'selected_book_chapter_not_found' => 'Izvēlētā grāmata vai nodaļa nav atrasta',
+    'guests_cannot_save_drafts' => 'Viesi nevar saglabāt melnrakstus',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Jūs nevarat dzēst vienīgo administratoru',
+    'users_cannot_delete_guest' => 'Jūs nevarat dzēst lietotāju "viesis"',
+
+    // Roles
+    'role_cannot_be_edited' => 'Šo lomu nevar rediģēt',
+    'role_system_cannot_be_deleted' => 'Šī ir sistēmas loma un nevar tikt izdzēsta',
+    'role_registration_default_cannot_delete' => 'Šī loma nevar tikt izdzēsta, kamēr tā uzstādīta kā noklusētā reģistrācijas loma',
+    'role_cannot_remove_only_admin' => 'Šis ir vienīgais lietotājs, kam norādīta administratora loma. Pievienojiet administratora lomu citam lietotājam pirms mēģiniet to izslēgt šeit.',
+
+    // Comments
+    'comment_list' => 'Radās kļūda ielasot komentārus.',
+    'cannot_add_comment_to_draft' => 'Melnrakstam nevar pievienot komentārus.',
+    'comment_add' => 'Radās kļūda pievienojot/atjaunojot komentāru.',
+    'comment_delete' => 'Radās kļūda dzēšot komentāru.',
+    'empty_comment' => 'Nevar pievienot tukšu komentāru.',
+
+    // Error pages
+    '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',
+    'back_soon' => 'Drīz būs atkal pieejams.',
+
+    // API errors
+    'api_no_authorization_found' => 'Pieprasījumā nav atrasts autorizācijas žetons',
+    'api_bad_authorization_format' => 'Pieprasījumā atrasts autorizācijas žetons, taču tā formāts nav pareizs',
+    'api_user_token_not_found' => 'Nav atrasts norādītajam autorizācijas žetonam atbilstošs API žetons',
+    'api_incorrect_token_secret' => 'Norādītā slepenā atslēga izmantotajam API žetonam nav pareiza',
+    'api_user_no_api_permission' => 'Izmantotā API žetona īpašniekam nav tiesības veikt API izsaukumus',
+    'api_user_token_expired' => 'Autorizācijas žetona derīguma termiņš ir izbeidzies',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Radusies kļūda sūtot testa epastu:',
+
+];
diff --git a/resources/lang/lv/pagination.php b/resources/lang/lv/pagination.php
new file mode 100644 (file)
index 0000000..c46d6dc
--- /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; Iepriekšējais',
+    'next'     => 'Nākamais &raquo;',
+
+];
diff --git a/resources/lang/lv/passwords.php b/resources/lang/lv/passwords.php
new file mode 100644 (file)
index 0000000..7d93957
--- /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' => 'Parolēm jābūt vismaz astoņu simbolu garām un jāatbilst apstiprinājumam.',
+    'user' => "Mēs nevaram atrast lietotāju ar šādu e-pasta adresi.",
+    'token' => 'Paroles atiestatīšanas atslēga neatbilst šai e-pasta adresei.',
+    'sent' => 'Esam nosūtījuši paroles atiestatīšanas saiti!',
+    'reset' => 'Parole ir atiestatīta!',
+
+];
diff --git a/resources/lang/lv/settings.php b/resources/lang/lv/settings.php
new file mode 100644 (file)
index 0000000..0108a9a
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Iestatījumi',
+    'settings_save' => 'Saglabāt iestatījumus',
+    'settings_save_success' => 'Iestatījumi saglabāti',
+
+    // App Settings
+    'app_customization' => 'Pielāgojumi',
+    'app_features_security' => 'Funkcijas un drošība',
+    'app_name' => 'Lietotnes nosaukums',
+    'app_name_desc' => 'Šis vārds tiks rādīts navigācijas joslā un sistēmas sūtītajis e-pastos.',
+    'app_name_header' => 'Rādīt vārdu navigācijas joslā',
+    'app_public_access' => 'Publiska piekļuve',
+    'app_public_access_desc' => 'Šīs opcijas ieslēgšana ļaus neautorizētiem apmeklētājiem piekļūt jūsu BookStack saturam.',
+    'app_public_access_desc_guest' => 'Publisku apmeklētāju piekļuvi var kontrolēt "Guest" (Viesa) lietotāja uzstādījumos.',
+    'app_public_access_toggle' => 'Atļaut publisku piekļuvi',
+    'app_public_viewing' => 'Atļaut publisku piekļuvi?',
+    'app_secure_images' => 'Paaugstinātas drošības attēlu ielāde',
+    'app_secure_images_toggle' => 'Ieslēgt paaugstinātas drošības attēlu ielādi',
+    'app_secure_images_desc' => 'Ātrdarbības nolūkos attēli ir publiski pieejami. Šī opcija pievieno nejaušu grūti uzminamu teksta virkni attēlu adresēs. Pārliecinieties kā ir izslēgta direktoriju pārlūkošana, lai nepieļautu vieglu piekļuvi šiem failiem.',
+    'app_editor' => 'Lapas redaktors',
+    'app_editor_desc' => 'Izvēlēties kurš redaktors tiks izmatots lapu rediģēšanai visiem lietotājiem.',
+    'app_custom_html' => 'Pielāgot HTML head saturu',
+    'app_custom_html_desc' => 'Šis saturs tiks pievienots <head> sadaļas apakšā visām lapām. Tas ir noderīgi papildinot CSS stilus vai pievienojot analītikas kodu.',
+    'app_custom_html_disabled_notice' => 'Pielāgots HTML head saturs ir izslēgts šajā uzstādījumu lapā, lai nodrošinātu, ka iespējams atcelt jebkādas kritiskas izmaiņas.',
+    'app_logo' => 'Lietotnes logo',
+    'app_logo_desc' => 'Attēlam jābūt 43px augstam. <br>Lielāki attēli tiks samazināti.',
+    'app_primary_color' => 'Galvenā aplikācijas krāsa',
+    'app_primary_color_desc' => 'Uzstāda primāro krāsu aplikācijai, ieskaitot banneri, pogas un saites.',
+    'app_homepage' => 'Aplikācijas sākumlapa',
+    'app_homepage_desc' => 'Izvēlēties skatu, ko rādīt sākumlapā noklusētā skata vietā. Lapas piekļuves tiesības izvēlētajai lapai netiks ņemtas vērā.',
+    'app_homepage_select' => 'Izvēlēties lapu',
+    'app_footer_links' => 'Kājenes saites',
+    'app_footer_links_desc' => 'Pievienot saites, ko attēlot lapas kājenē. Tās tiks attēlotas lielākās daļas lapu apakšā, ieskaitot tās, kas pieejamas bez reģistrācijas. Jūs varat izmantot nosaukumu "trans::<key>", lai izmantotu sistēmā definētus tulkojumus. Piemēram, "trans::common.privacy_policy" tiks aizvietots ar tulkoto tekstu "Privātuma politika" un "trans::common.terms_of_service" kļūs par "Lietošanas noteikumi".',
+    'app_footer_links_label' => 'Saites nosaukums',
+    'app_footer_links_url' => 'Saites URL',
+    'app_footer_links_add' => 'Pievienot kājenes saiti',
+    'app_disable_comments' => 'Izslēgt komentārus',
+    'app_disable_comments_toggle' => 'Izslēgt komentārus',
+    'app_disable_comments_desc' => 'Atslēdz komentārus visās aplikācijas lapās.<br> Jau eksistējoši komentāri netiks attēloti.',
+
+    // Color settings
+    'content_colors' => 'Satura krāsas',
+    'content_colors_desc' => 'Norādīt krāsas visiem lapas hierarhijas elementiem. Lasāmības labad ieteicams izvēlēties krāsas ar līdzīgu spilgtumu kā noklusētajām.',
+    'bookshelf_color' => 'Plaukta krāsa',
+    'book_color' => 'Grāmatas krāsa',
+    'chapter_color' => 'Nodaļas krāsa',
+    'page_color' => 'Lapas krāsa',
+    'page_draft_color' => 'Lapas uzmetuma krāsa',
+
+    // Registration Settings
+    'reg_settings' => 'Reģistrācija',
+    'reg_enable' => 'Iespējot reģistrāciju',
+    'reg_enable_toggle' => 'Iespējot reģistrāciju',
+    'reg_enable_desc' => 'Kad reģistrācija ir ieslēgta, lietotāji varēs paši reģistrēties kā aplikācijas lietotāji. Pēc reģistrācijas tiem tiks piešķirta noklusētā lietotāja loma.',
+    'reg_default_role' => 'Noklusētā lietotāja loma pēc reģistrācijas',
+    'reg_enable_external_warning' => 'Šis uzstādījums tiek ignorēts kamēr tiek izmantota ārēja LDAP vai SAML autentifikācija. Tiks izveidoti lietotāju konti neeksistējošiem leitotājiem, ja autentifikācija pret ārējo sistēmu būs veiksmīga.',
+    'reg_email_confirmation' => 'E-pasta apstiprinājums',
+    'reg_email_confirmation_toggle' => 'Pieprasīt epasta apstiprināšanu',
+    'reg_confirm_email_desc' => 'Ja ieslēgts domēnu ierobežojums, tad būs nepieciešama epasta apstiprināšana un šis uzstādījums tiks ignorēts.',
+    'reg_confirm_restrict_domain' => 'Domēnu ierobežojums',
+    'reg_confirm_restrict_domain_desc' => 'Ievadiet ar komatiem atdalītu sarakstu ar epasta domēniem, kam jūs gribētu atļaut reģistrāciju. Lietotājiem tiks nosūtīts epasts, lai apstiprinātu tā adresi pirms tiks ļauts darboties ar aplikāciju. <br> Ņemiet vērā, ka lietotāji varēs nomainīt savu epasta adresi pēc veiksmīgas reģistrācijas.',
+    'reg_confirm_restrict_domain_placeholder' => 'Nav ierobežojumu',
+
+    // 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_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?',
+    'maint_image_cleanup_success' => ':count iespējami neizmantoti attēli atrasti un izdzēsti!',
+    'maint_image_cleanup_nothing_found' => 'Nav atrasti neizmantoti attēli, nekas netika izdzēsts!',
+    'maint_send_test_email' => 'Nosūtīt testa epastu',
+    'maint_send_test_email_desc' => 'Nosūtīt testa epastu uz jūsu profilā norādīto epasta adresi.',
+    'maint_send_test_email_run' => 'Nosūtīt testa epastu',
+    'maint_send_test_email_success' => 'Epasts nosūtīts uz :address',
+    'maint_send_test_email_mail_subject' => 'Testa epasts',
+    'maint_send_test_email_mail_greeting' => 'Izskatās, ka epasta piegāde strādā!',
+    'maint_send_test_email_mail_text' => 'Apsveicam! Tā kā jūs saņēmāt šo epasta paziņojumu, jūsu epasta uzstādījumi šķiet pareizi.',
+    'maint_recycle_bin_desc' => 'Dzēstie plaukti, grāmatas, nodaļas un lapas ir pārceltas uz miskasti, lai tos varētu atjaunot vai izdzēst pilnībā. Vecākas vienības miskastē var tikt automātiski dzēstas pēc kāda laika atkarībā no sistēmas uzstādījumiem.',
+    'maint_recycle_bin_open' => 'Atvērt miskasti',
+
+    // Recycle Bin
+    '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' => 'Parent',
+    'recycle_bin_deleted_by' => 'Izdzēsa',
+    'recycle_bin_deleted_at' => 'Dzēšanas laiks',
+    'recycle_bin_permanently_delete' => 'Neatgriezeniski izdzēst',
+    'recycle_bin_restore' => 'Atjaunot',
+    'recycle_bin_contents_empty' => 'Miskaste ir tukša',
+    'recycle_bin_empty' => 'Iztīrīt miskasti',
+    'recycle_bin_empty_confirm' => 'Šī darbība pilnībā dzēsīs visas vienības miskastē, ieskaitot saturu, kas ievietots katrā no šīm vienībām. Vai tiešām vēlaties dzēst visu miskastes saturu?',
+    'recycle_bin_destroy_confirm' => 'Šī darbība pilnībā izdzēsis šo vienību kopā ar tai pakārtotajiem elementiem no sistēmas, un jūs nevarēsiet šo saturu atjaunot. Vai tiešām vēlaties pilnībā izdzēst šo vienību?',
+    'recycle_bin_destroy_list' => 'Dzēšamās vienības',
+    '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' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'Dzēstas kopā :count vienības no miskastes.',
+    'recycle_bin_restore_notification' => 'Atjaunotas kopā :count vienības no miskastes.',
+
+    // Audit Log
+    'audit' => 'Auditācijas pieraksti',
+    'audit_desc' => 'Šie auditācijas pieraksti attēlo sarakstu ar sistēmā reģistrētajām aktivitātēm. Šis saraksts nav filtrēts atšķirībā no līdzīgiem aktivitāšu sarakstiem sistēmā, kur ir piemēroti atļauto darbību filtri.',
+    'audit_event_filter' => 'Notikumu filtrs',
+    'audit_event_filter_no_filter' => 'Bez filtra',
+    'audit_deleted_item' => 'Dzēsta vienība',
+    'audit_deleted_item_name' => 'Vārds: :name',
+    'audit_table_user' => 'Lietotājs',
+    'audit_table_event' => 'Notikums',
+    'audit_table_related' => 'Saistīta vienība vai detaļa',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Notikuma datums',
+    'audit_date_from' => 'Datums no',
+    'audit_date_to' => 'Datums līdz',
+
+    // Role Settings
+    'roles' => 'Grupas',
+    'role_user_roles' => 'Lietotāju grupas',
+    'role_create' => 'Izveidot jaunu grupu',
+    'role_create_success' => 'Grupa veiksmīgi izveidota',
+    'role_delete' => 'Dzēst grupu',
+    'role_delete_confirm' => 'Loma \':roleName\' tiks dzēsta.',
+    'role_delete_users_assigned' => 'Šajā grupā ir pievienoti :userCount lietotāji. Ja vēlaties pārvietot lietotājus no šīs grupas, tad izvēlaties kādu no zemāk redzamajām grupām.',
+    'role_delete_no_migration' => "Nepārvietot lietotājus",
+    'role_delete_sure' => 'Vai tiešām vēlaties dzēst grupu?',
+    'role_delete_success' => 'Grupa veiksmīgi dzēsta',
+    'role_edit' => 'Rediģēt grupu',
+    'role_details' => 'Informācija par grupu',
+    'role_name' => 'Grupas nosaukums',
+    'role_desc' => 'Īss grupas apaksts',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    'role_external_auth_id' => 'Ārējais autentifikācijas ID',
+    'role_system' => 'Sistēmas atļaujas',
+    'role_manage_users' => 'Pārvaldīt lietotājus',
+    'role_manage_roles' => 'Pārvaldīt grupas un grupu atļaujas',
+    'role_manage_entity_permissions' => 'Pārvaldīt visu grāmatu, nodaļu un lapu atļaujas',
+    'role_manage_own_entity_permissions' => 'Pārvaldīt atļaujas savām grāmatām, nodaļām un lapām',
+    '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' => 'Export content',
+    'role_asset' => 'Resursa piekļuves tiesības',
+    'roles_system_warning' => 'Jebkuras no trīs augstāk redzamajām atļaujām dod iespēju lietotājam mainīt savas un citu lietotāju sistēmas atļaujas. Pievieno šīs grupu atļaujas tikai tiem lietotājiem, kuriem uzticies.',
+    'role_asset_desc' => 'Šīs piekļuves tiesības kontrolē noklusēto piekļuvi sistēmas resursiem. Grāmatām, nodaļām un lapām norādītās tiesības būs pārākas par šīm.',
+    'role_asset_admins' => 'Administratoriem automātiski ir piekļuve visam saturam, bet šie uzstādījumi var noslēpt vai parādīt lietotāja saskarnes iespējas.',
+    'role_all' => 'Visi',
+    'role_own' => 'Savi',
+    'role_controlled_by_asset' => 'Kontrolē resurss, uz ko tie ir augšupielādēti',
+    'role_save' => 'Saglabāt grupu',
+    'role_update_success' => 'Grupa veiksmīgi atjaunināta',
+    'role_users' => 'Lietotāji šajā grupā',
+    'role_users_none' => 'Pagaidām neviens lietotājs nav pievienots šai grupai',
+
+    // Users
+    'users' => 'Lietotāji',
+    'user_profile' => 'Lietotāja profils',
+    'users_add_new' => 'Pievienot jaunu lietotāju',
+    'users_search' => 'Meklēt lietotājus',
+    'users_latest_activity' => 'Pēdējās aktivitātes',
+    'users_details' => 'Lietotāja informācija',
+    'users_details_desc' => 'Uzstādīt attēlojamo vārdu un epast adresi šim lietotājam. Epasta adresi varēs izmantot, lai piekļūtu aplikācijai.',
+    'users_details_desc_no_email' => 'Uzstādiet attēlojamu vārdu šim lietotājam, lai citi varētu viņu atpazīt.',
+    '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_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',
+    'users_external_auth_id_desc' => 'Šis ir identifikators, kas tiks izmantots, lai atpazītu lietotāju, sazinoaties ar jūsu ārējo autentifikācijas sistēmu.',
+    'users_password_warning' => 'Aizpildiet tikai tad, ja vēlaties mainīt savu paroli.',
+    'users_system_public' => 'Šis lietotājs apzīmē visus viesus, kas apmeklēs jūsu lapu. To nevar izmantot lapas piekļuvei un tas tiek norādīts automātiski.',
+    'users_delete' => 'Dzēst lietotāju',
+    'users_delete_named' => 'Dzēst lietotāju :userName',
+    'users_delete_warning' => 'Šī darbība pilnībā izdzēsīs lietotāju \':userName\' no sistēmas.',
+    'users_delete_confirm' => 'Vai tiešām vēlaties dzēst šo lietotāju?',
+    'users_migrate_ownership' => 'Pārcelt īpašumtiesības',
+    'users_migrate_ownership_desc' => 'Izvēlieties lietotāju, ja vēlaties citam lietotājam pārcelt pašlaik šim lietotājam piederošās vienības.',
+    'users_none_selected' => 'Nav izvēlēts lietotājs',
+    'users_delete_success' => 'Lietotājs veiksmīgi dzēsts',
+    'users_edit' => 'Rediģēt lietotāju',
+    'users_edit_profile' => 'Rediģēt profilu',
+    'users_edit_success' => 'Lietotājs veiksmīgi atjaunināts',
+    'users_avatar' => 'Lietotāja attēls',
+    'users_avatar_desc' => 'Izvēlieties attēlu šim lietotājam. Tam vajadzētu būt apmēram 256px kvadrātam.',
+    'users_preferred_language' => 'Vēlamā valoda',
+    'users_preferred_language_desc' => 'Šis uzstādījums nomainīs valodu, kas izmantota aplikācijas lietotāja saskarnē. Tas neietekmēs neko no lietotāju radītā satura.',
+    'users_social_accounts' => 'Sociālie konti',
+    'users_social_accounts_info' => 'Te jūs varat pieslēgt citus kontus ātrākai un ērtākai piekļuvei. Konta atvienošana no šejienes neatceļ šai aplikācijai dotās tiesības šī konta piekļuvei. Atvienojtiet piekļuvi arī no jūsu profila uzstādījumiem pievienotajā sociālajā kontā.',
+    'users_social_connect' => 'Pievienot kontu',
+    'users_social_disconnect' => 'Atvienot kontu',
+    'users_social_connected' => ':socialAccount konts veiksmīgi pieslēgts jūsu profilam.',
+    'users_social_disconnected' => ':socialAccount konts veiksmīgi atslēgts no jūsu profila.',
+    'users_api_tokens' => 'API žetoni',
+    'users_api_tokens_none' => 'Šim lietotājam nav izveidotu API žetonu',
+    'users_api_tokens_create' => 'Izveidot žetonu',
+    'users_api_tokens_expires' => 'Derīguma termiņš',
+    'users_api_tokens_docs' => 'API dokumentācija',
+    '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' => 'Izveidot API žetonu',
+    'user_api_token_name' => 'Vārds',
+    'user_api_token_name_desc' => 'Uzstādiet nolasāmu nosaukumu savam žetonam, lai nākotnē atgadinātu par tā pielietojumu.',
+    'user_api_token_expiry' => 'Derīgs līdz',
+    'user_api_token_expiry_desc' => 'Uzstādiet datumu, kad beidzas žetona derīguma termiņš. Pieprasījumi, kas veikti pēc šī datuma ar šo žetonu vairs nedarbosies. Atstājot lauku tukšu, tiks uzstādīts derīguma termiņš 100 gadu nākotnē.',
+    'user_api_token_create_secret_message' => 'Uzreiz pēc žetona izveidošanas tiks parādīts žetona ID un žetona noslēpums. Šis noslēpums tiks attēlots tikai vienreiz, tāpēc pārliecinieties, ka tā vērtība ir nokopēta uz kādu citu drošu vietu pirms turpināšanas.',
+    'user_api_token_create_success' => 'API žetons veiksmīgi izveidots',
+    'user_api_token_update_success' => 'API žetons veiksmīgi atjaunināts',
+    'user_api_token' => 'API žetons',
+    'user_api_token_id' => 'Žetona ID',
+    'user_api_token_id_desc' => 'Šis ir neizmaināms sistēmas ģenerēts identifikators šim žetonam, kas būs jānorāda API pieprasījumos.',
+    'user_api_token_secret' => 'Žetona noslēpums',
+    'user_api_token_secret_desc' => 'Šis ir sistēmas ģenerēts noslēpums šim žetonam, ko būs nepieciešams norādīt API pieprasījumos. Tas tiks attēlots tikai vienu reizi, tāpēc nokopējiet to uz kādu citu drošu vietu.',
+    'user_api_token_created' => 'Žetons izveidots :timeAgo',
+    'user_api_token_updated' => 'Žetons atjaunināts :timeAgo',
+    'user_api_token_delete' => 'Dzēst žetonu',
+    'user_api_token_delete_warning' => 'Šī darbība pilnībā izdzēsīs API žetonu \':tokenName\' no sistēmas.',
+    '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',
+
+    //! 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' => 'Katalāņu',
+        'cs' => 'Česky',
+        'da' => 'Dansk',
+        'de' => 'Deutsch (Sie)',
+        'de_informal' => 'Deutsch (Du)',
+        'es' => 'Español',
+        'es_AR' => 'Español Argentina',
+        '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/lv/validation.php b/resources/lang/lv/validation.php
new file mode 100644 (file)
index 0000000..8dd9397
--- /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 ir jāapstiprina.',
+    'active_url'           => ':attribute nav derīgs URL.',
+    'after'                => ':attribute ir jābūt datumam pēc :date.',
+    'alpha'                => ':attribute var saturēt tikai burtus.',
+    '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.',
+        'file'    => ':attribute jābūt starp :min un :max kilobaitiem.',
+        'string'  => ':attribute jābūt starp :min un :max rakstzīmēm.',
+        'array'   => 'Atribūtam jābūt starp: min un: max vienumiem.',
+    ],
+    'boolean'              => ':attribute jābūt True vai False.',
+    'confirmed'            => ':attribute apstiprinājums nesakrīt.',
+    'date'                 => ':attribute nav derīgs datums.',
+    'date_format'          => ':attribute neatbilst formātam :format.',
+    'different'            => ':attribute un :other jābūt atšķirīgiem.',
+    'digits'               => ':attribute jābūt :digits cipariem.',
+    'digits_between'       => ':attribute jābūt starp :min un :max cipariem.',
+    'email'                => ':attribute jābūt derīgai e-pasta adresei.',
+    'ends_with' => ':attribute jābeidzas ar vienu no :values',
+    'filled'               => ':attribute lauks ir obligāts.',
+    'gt'                   => [
+        'numeric' => ':attribute jābūt lielākam kā :value.',
+        'file'    => ':attribute jābūt lielākam kā :value kilobaitiem.',
+        'string'  => ':attribute jābūt lielākam kā :value rakstzīmēm.',
+        'array'   => ':attribute jāsatur vairāk kā :value vienības.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute jābūt lielākam vai vienādam ar :value.',
+        'file'    => ':attribute jābūt lielākam vai vienādam ar :value kilobaitiem.',
+        'string'  => ':attribute jābūt lielākam vai vienādam ar :value rakstzīmēm.',
+        'array'   => ':attribute jāsatur :value vai vairāk vienumus.',
+    ],
+    'exists'               => 'Izvēlētais :attribute ir nederīgs.',
+    'image'                => ':attribute jābūt attēlam.',
+    'image_extension'      => ':attribute jābūt derīgam un atbalstītam bildes paplašinājumam.',
+    'in'                   => 'Iezīmētais :attribute ir nederīgs.',
+    'integer'              => ':attribute ir jābūt veselam skaitlim.',
+    'ip'                   => ':attribute jābūt derīgai IP adresei.',
+    'ipv4'                 => ':attribute jābūt derīgai IPv4 adresei.',
+    'ipv6'                 => ':attribute jābūt derīgai IPv6 adresei.',
+    'json'                 => ':attribute jābūt derīgai JSON virknei.',
+    'lt'                   => [
+        'numeric' => ':attribute jābūt mazākam par :value.',
+        'file'    => ':attribute jābūt mazāk kā :value kilobaitiem.',
+        'string'  => ':attribute jābūt mazāk kā :value rakstzīmēm.',
+        'array'   => ':attribute jāsatur mazāk kā :value vienības.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute jābūt mazākam vai vienādam ar :value.',
+        'file'    => ':attribute jābūt mazākam vai vienādam ar :value kilobaitiem.',
+        'string'  => ':attribute jābūt mazākam vai vienādam ar :value rakstzīmēm.',
+        'array'   => ':attribute nedrīkst pārsniegt :value vienības.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute nevar būt lielāks kā :max.',
+        'file'    => ':attribute nedrīkst būt lielāks kā :max kilobaiti.',
+        'string'  => ':attribute nedrīkst būt lielāks kā :max rakstzīmēm.',
+        'array'   => ':attribute nedrīkst būt lielāks kā :max vienumi.',
+    ],
+    'mimes'                => ':attribute jābūt faila tipam: :values.',
+    'min'                  => [
+        'numeric' => ':attribute ir jābūt vismaz :min.',
+        'file'    => ':attribute jābūt vismaz :min kilobaitiem.',
+        'string'  => ':attribute ir jābūt vismaz :min rakstzīmēm.',
+        'array'   => ':attribute ir jābūt vismaz :min vienībām.',
+    ],
+    'not_in'               => 'Izvēlētais: atribūts ir nederīgs.',
+    'not_regex'            => ':attribute formāts nav derīgs.',
+    'numeric'              => ':attribute ir jābūt skaitlim.',
+    'regex'                => ':attribute formāts nav derīgs.',
+    'required'             => ':attribute lauks ir obligāts.',
+    'required_if'          => ':attribute lauks ir nepieciešams, kad :other ir :value.',
+    'required_with'        => ':attribute lauks ir obligāts, ja ir :values.',
+    'required_with_all'    => ':attribute lauks ir obligāts, ja ir :values.',
+    'required_without'     => ':attribute lauks ir obligāts, ja nav :values.',
+    'required_without_all' => ':attribute lauks ir obligāts, ja nav neviena no :values.',
+    'same'                 => ':attribute un :other jāsakrīt.',
+    'safe_url'             => 'Norādītā saite var būt nedroša.',
+    'size'                 => [
+        'numeric' => ':attribute ir jābūt :size.',
+        'file'    => ':attribute jābūt :size kilobaiti.',
+        'string'  => ':attribute jābūt :size rakstzīmēm.',
+        'array'   => ':attribute jāsatur :size vienības.',
+    ],
+    '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.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Nepieciešams paroles apstiprinājums',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
diff --git a/resources/lang/nb/activities.php b/resources/lang/nb/activities.php
new file mode 100644 (file)
index 0000000..7313f37
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Activity text strings.
+ * Is used for all the text within activity logs & notifications.
+ */
+return [
+
+    // Pages
+    'page_create'                 => 'opprettet side',
+    'page_create_notification'    => 'Siden ble opprettet',
+    'page_update'                 => 'oppdaterte side',
+    'page_update_notification'    => 'Siden ble oppdatert',
+    'page_delete'                 => 'slettet side',
+    'page_delete_notification'    => 'Siden ble slettet',
+    'page_restore'                => 'gjenopprettet side',
+    'page_restore_notification'   => 'Siden ble gjenopprettet',
+    'page_move'                   => 'flyttet side',
+
+    // Chapters
+    'chapter_create'              => 'opprettet kapittel',
+    'chapter_create_notification' => 'Kapittelet ble opprettet',
+    'chapter_update'              => 'oppdaterte kapittel',
+    'chapter_update_notification' => 'Kapittelet ble oppdatert',
+    'chapter_delete'              => 'slettet kapittel',
+    'chapter_delete_notification' => 'Kapittelet ble slettet',
+    'chapter_move'                => 'flyttet kapittel
+    ',
+
+    // Books
+    'book_create'                 => 'opprettet bok',
+    'book_create_notification'    => 'Boken ble opprettet',
+    'book_update'                 => 'oppdaterte bok',
+    'book_update_notification'    => 'Boken ble oppdatert',
+    'book_delete'                 => 'slettet bok',
+    'book_delete_notification'    => 'Boken ble slettet',
+    'book_sort'                   => 'sorterte bok',
+    'book_sort_notification'      => 'Boken ble omsortert',
+
+    // Bookshelves
+    'bookshelf_create'            => 'opprettet bokhylle',
+    'bookshelf_create_notification'    => 'Bokhyllen ble opprettet',
+    'bookshelf_update'                 => 'oppdaterte bokhylle',
+    'bookshelf_update_notification'    => 'Bokhyllen ble oppdatert',
+    'bookshelf_delete'                 => 'slettet bokhylle',
+    'bookshelf_delete_notification'    => 'Bokhyllen ble slettet',
+
+    // 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',
+
+    // Other
+    'commented_on'                => 'kommenterte på',
+    'permissions_update'          => 'oppdaterte tilganger',
+];
diff --git a/resources/lang/nb/auth.php b/resources/lang/nb/auth.php
new file mode 100644 (file)
index 0000000..4c1f555
--- /dev/null
@@ -0,0 +1,112 @@
+<?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' => 'Disse detaljene samsvarer ikke med det vi har på bok.',
+    'throttle' => 'For mange forsøk, prøv igjen om :seconds sekunder.',
+
+    // Login & Register
+    'sign_up' => 'Registrer deg',
+    'log_in' => 'Logg inn',
+    'log_in_with' => 'Logg inn med :socialDriver',
+    'sign_up_with' => 'Registrer med :socialDriver',
+    'logout' => 'Logg ut',
+
+    'name' => 'Navn',
+    'username' => 'Brukernavn',
+    'email' => 'E-post',
+    'password' => 'Passord',
+    'password_confirm' => 'Bekreft passord',
+    'password_hint' => 'Må inneholde 7 tegn',
+    'forgot_password' => 'Glemt passord?',
+    'remember_me' => 'Husk meg',
+    'ldap_email_hint' => 'Oppgi en e-post for denne kontoen.',
+    'create_account' => 'Opprett konto',
+    'already_have_account' => 'Har du allerede en konto?',
+    'dont_have_account' => 'Mangler du en konto?',
+    'social_login' => 'Sosiale kontoer',
+    'social_registration' => 'Registrer via sosiale kontoer',
+    'social_registration_text' => 'Bruk en annen tjeneste for å registrere deg.',
+
+    'register_thanks' => 'Takk for at du registrerte deg!',
+    'register_confirm' => 'Sjekk e-posten din for informasjon som gir deg tilgang til :appName.',
+    'registrations_disabled' => 'Registrering er deaktivert.',
+    '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.',
+    'reset_password_send_button' => 'Send nullstillingslenke',
+    'reset_password_sent' => 'En nullstillingslenke ble sendt til :email om den eksisterer i systemet.',
+    'reset_password_success' => 'Passordet ble nullstilt.',
+    'email_reset_subject' => 'Nullstill ditt :appName passord',
+    '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_resent' => 'Bekreftelsespost ble sendt, sjekk innboksen din.',
+
+    'email_not_confirmed' => 'E-posten er ikke bekreftet.',
+    'email_not_confirmed_text' => 'Epost-adressen er ennå ikke bekreftet.',
+    'email_not_confirmed_click_link' => 'Trykk på lenken i e-posten du fikk vedrørende din registrering.',
+    'email_not_confirmed_resend' => 'Om du ikke finner den i innboksen eller søppelboksen, kan du få tilsendt ny ved å trykke på knappen under.',
+    'email_not_confirmed_resend_button' => 'Send bekreftelsespost på nytt',
+
+    // User Invite
+    'user_invite_email_subject' => 'Du har blitt invitert til :appName!',
+    'user_invite_email_greeting' => 'En konto har blitt opprettet for deg på :appName.',
+    'user_invite_email_text' => 'Trykk på knappen under for å opprette et sikkert passord:',
+    'user_invite_email_action' => 'Angi passord',
+    '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!',
+
+    // 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.',
+];
\ No newline at end of file
diff --git a/resources/lang/nb/common.php b/resources/lang/nb/common.php
new file mode 100644 (file)
index 0000000..8ba4e74
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * Common elements found throughout many areas of BookStack.
+ */
+return [
+
+    // Buttons
+    'cancel' => 'Avbryt',
+    'confirm' => 'Bekreft',
+    'back' => 'Tilbake',
+    'save' => 'Lagre',
+    'continue' => 'Fortsett',
+    'select' => 'Velg',
+    'toggle_all' => 'Bytt alle',
+    'more' => 'Mer',
+
+    // Form Labels
+    'name' => 'Navn',
+    'description' => 'Beskrivelse',
+    'role' => 'Rolle',
+    'cover_image' => 'Bokomslag',
+    'cover_image_description' => 'Bildet bør være ca. 440x250px.',
+    
+    // Actions
+    'actions' => 'Handlinger',
+    'view' => 'Vis',
+    'view_all' => 'Vis alle',
+    'create' => 'Opprett',
+    'update' => 'Oppdater',
+    'edit' => 'Rediger',
+    'sort' => 'Sorter',
+    'move' => 'Flytt',
+    'copy' => 'Kopier',
+    'reply' => 'Svar',
+    'delete' => 'Slett',
+    'delete_confirm' => 'Bekreft sletting',
+    'search' => 'Søk',
+    'search_clear' => 'Nullstill søk',
+    'reset' => 'Nullstill',
+    'remove' => 'Fjern',
+    'add' => 'Legg til',
+    'configure' => 'Konfigurer',
+    'fullscreen' => 'Fullskjerm',
+    'favourite' => 'Favorisér',
+    'unfavourite' => 'Avfavorisér',
+    'next' => 'Neste',
+    'previous' => 'Forrige',
+
+    // Sort Options
+    'sort_options' => 'Sorteringsalternativer',
+    'sort_direction_toggle' => 'Sorteringsretning',
+    'sort_ascending' => 'Stigende sortering',
+    'sort_descending' => 'Synkende sortering',
+    'sort_name' => 'Navn',
+    'sort_default' => 'Standard',
+    'sort_created_at' => 'Dato opprettet',
+    'sort_updated_at' => 'Dato oppdatert',
+
+    // Misc
+    'deleted_user' => 'Slett bruker',
+    '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',
+    'grid_view' => 'Rutenettvisning',
+    'list_view' => 'Listevisning',
+    'default' => 'Standard',
+    'breadcrumb' => 'Brødsmuler',
+
+    // Header
+    'header_menu_expand' => 'Utvid toppmeny',
+    'profile_menu' => 'Profilmeny',
+    'view_profile' => 'Vis profil',
+    'edit_profile' => 'Endre Profile',
+    'dark_mode' => 'Kveldsmodus',
+    'light_mode' => 'Dagmodus',
+
+    // Layout tabs
+    'tab_info' => 'Informasjon',
+    'tab_info_label' => 'Fane: Vis tilleggsinfo',
+    'tab_content' => 'Innhold',
+    '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:',
+    'email_rights' => 'Kopibeskyttet',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Personvernregler',
+    'terms_of_service' => 'Bruksvilkår',
+];
diff --git a/resources/lang/nb/components.php b/resources/lang/nb/components.php
new file mode 100644 (file)
index 0000000..cfc28c4
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Text used in custom JavaScript driven components.
+ */
+return [
+
+    // Image Manager
+    'image_select' => 'Velg bilde',
+    'image_all' => 'Alle',
+    'image_all_title' => 'Vis alle bilder',
+    'image_book_title' => 'Vis bilder som er lastet opp i denne boken',
+    'image_page_title' => 'Vis bilder lastet opp til denne siden',
+    'image_search_hint' => 'Søk på bilder etter navn',
+    'image_uploaded' => 'Opplastet :uploadedDate',
+    'image_load_more' => 'Last in flere',
+    'image_image_name' => 'Bildenavn',
+    'image_delete_used' => 'Dette bildet er brukt på sidene nedenfor.',
+    'image_delete_confirm_text' => 'Vil du slette dette bildet?',
+    'image_select_image' => 'Velg bilde',
+    'image_dropzone' => 'Dra og slipp eller trykk her for å laste opp bilder',
+    'images_deleted' => 'Bilder slettet',
+    'image_preview' => 'Hurtigvisning av bilder',
+    'image_upload_success' => 'Bilde ble lastet opp',
+    'image_update_success' => 'Bildedetaljer ble oppdatert',
+    'image_delete_success' => 'Bilde ble slettet',
+    'image_upload_remove' => 'Fjern',
+
+    // Code Editor
+    'code_editor' => 'Endre kode',
+    'code_language' => 'Kodespråk',
+    'code_content' => 'Kodeinnhold',
+    'code_session_history' => 'Sesjonshistorikk',
+    'code_save' => 'Lagre kode',
+];
diff --git a/resources/lang/nb/entities.php b/resources/lang/nb/entities.php
new file mode 100644 (file)
index 0000000..a50aa71
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * Text used for 'Entities' (Document Structure Elements) such as
+ * Books, Shelves, Chapters & Pages
+ */
+return [
+
+    // Shared
+    'recently_created' => 'Nylig opprettet',
+    'recently_created_pages' => 'Nylig opprettede sider',
+    'recently_updated_pages' => 'Nylig oppdaterte sider',
+    'recently_created_chapters' => 'Nylig opprettede kapitler',
+    'recently_created_books' => 'Nylig opprettede bøker',
+    'recently_created_shelves' => 'Nylig opprettede bokhyller',
+    'recently_update' => 'Nylig oppdatert',
+    'recently_viewed' => 'Nylig vist',
+    'recent_activity' => 'Nylig aktivitet',
+    'create_now' => 'Opprett en nå',
+    'revisions' => 'Revisjoner',
+    'meta_revision' => 'Revisjon #:revisionCount',
+    'meta_created' => 'Opprettet :timeLength',
+    'meta_created_name' => 'Opprettet :timeLength av :user',
+    'meta_updated' => 'Oppdatert :timeLength',
+    'meta_updated_name' => 'Oppdatert :timeLength av :user',
+    'meta_owned_name' => 'Eies av :user',
+    'entity_select' => 'Velg entitet',
+    'images' => 'Bilder',
+    'my_recent_drafts' => 'Mine nylige utkast',
+    'my_recently_viewed' => 'Mine nylige visninger',
+    'my_most_viewed_favourites' => 'Mine mest viste 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',
+    'export' => 'Eksporter',
+    'export_html' => 'Nettside med alt',
+    'export_pdf' => 'PDF Fil',
+    'export_text' => 'Tekstfil',
+    'export_md' => 'Markdownfil',
+
+    // Permissions and restrictions
+    'permissions' => 'Tilganger',
+    'permissions_intro' => 'Når disse er tillatt, vil disse tillatelsene ha prioritet over alle angitte rolletillatelser.',
+    'permissions_enable' => 'Aktiver egendefinerte tillatelser',
+    'permissions_save' => 'Lagre tillatelser',
+    'permissions_owner' => 'Eier',
+
+    // Search
+    'search_results' => 'Søkeresultater',
+    'search_total_results_found' => ':count resultater funnet|:count totalt',
+    'search_clear' => 'Nullstill søk',
+    'search_no_pages' => 'Ingen sider passer med søket',
+    'search_for_term' => 'Søk etter :term',
+    'search_more' => 'Flere resultater',
+    'search_advanced' => 'Avansert søk',
+    'search_terms' => 'Søkeord',
+    'search_content_type' => 'Innholdstype',
+    'search_exact_matches' => 'Eksakte ord',
+    'search_tags' => 'Søk på merker',
+    'search_options' => 'ALternativer',
+    'search_viewed_by_me' => 'Sett av meg',
+    'search_not_viewed_by_me' => 'Ikke sett av meg',
+    'search_permissions_set' => 'Tilganger er angitt',
+    'search_created_by_me' => 'Opprettet av meg',
+    'search_updated_by_me' => 'Oppdatert av meg',
+    'search_owned_by_me' => 'Eid av meg',
+    'search_date_options' => 'Datoalternativer',
+    'search_updated_before' => 'Oppdatert før',
+    'search_updated_after' => 'Oppdatert etter',
+    'search_created_before' => 'Opprettet før',
+    'search_created_after' => 'Opprettet etter',
+    'search_set_date' => 'Angi dato',
+    'search_update' => 'Oppdater søk',
+
+    // Shelves
+    'shelf' => 'Hylle',
+    'shelves' => 'Hyller',
+    'x_shelves' => ':count hylle|:count hyller',
+    'shelves_long' => 'Bokhyller',
+    'shelves_empty' => 'Ingen bokhyller er opprettet',
+    'shelves_create' => 'Opprett ny bokhylle',
+    'shelves_popular' => 'Populære bokhyller',
+    'shelves_new' => 'Nye bokhyller',
+    'shelves_new_action' => 'Ny bokhylle',
+    'shelves_popular_empty' => 'De mest populære bokhyllene blir vist her.',
+    'shelves_new_empty' => 'Nylig opprettede bokhyller vises her.',
+    'shelves_save' => 'Lagre hylle',
+    'shelves_books' => 'Bøker på denne hyllen',
+    'shelves_add_books' => 'Legg til bøker på denne hyllen',
+    'shelves_drag_books' => 'Dra bøker hit for å stable dem i denne hylla',
+    'shelves_empty_contents' => 'INgen bøker er stablet i denne hylla',
+    'shelves_edit_and_assign' => 'Endre hylla for å legge til bøker',
+    'shelves_edit_named' => 'Endre hyllen :name',
+    'shelves_edit' => 'Endre bokhylle',
+    'shelves_delete' => 'Fjern bokhylle',
+    'shelves_delete_named' => 'Fjern bokhyllen :name',
+    'shelves_delete_explain' => "Dette vil fjerne bokhyllen ':name'. Bøkene vil ikke fjernes fra systemet.",
+    'shelves_delete_confirmation' => 'Er du helt sikker på at du vil skru ned hylla?',
+    'shelves_permissions' => 'Tilganger til hylla',
+    'shelves_permissions_updated' => 'Hyllas tilganger er oppdatert',
+    'shelves_permissions_active' => 'Hyllas tilganger er aktive',
+    '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' => '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.',
+    'shelves_copy_permission_success' => 'Tilgangene ble overført til :count bøker',
+
+    // Books
+    'book' => 'Bok',
+    'books' => 'Bøker',
+    'x_books' => ':count bok|:count bøker',
+    'books_empty' => 'Ingen bøker er skrevet',
+    'books_popular' => 'Populære bøker',
+    'books_recent' => 'Nylige bøker',
+    'books_new' => 'Nye bøker',
+    'books_new_action' => 'Ny bok',
+    'books_popular_empty' => 'De mest populære bøkene',
+    'books_new_empty' => 'Siste utgivelser vises her.',
+    'books_create' => 'Skriv ny bok',
+    'books_delete' => 'Brenn bok',
+    'books_delete_named' => 'Brenn boken :bookName',
+    'books_delete_explain' => 'Dette vil brenne boken «:bookName». Alle sider i boken vil fordufte for godt.',
+    'books_delete_confirmation' => 'Er du sikker på at du vil brenne boken?',
+    'books_edit' => 'Endre bok',
+    'books_edit_named' => 'Endre boken :bookName',
+    'books_form_book_name' => 'Boktittel',
+    'books_save' => 'Lagre bok',
+    'books_permissions' => 'Boktilganger',
+    'books_permissions_updated' => 'Boktilganger oppdatert',
+    'books_empty_contents' => 'Ingen sider eller kapitler finnes i denne boken.',
+    'books_empty_create_page' => 'Skriv en ny side',
+    'books_empty_sort_current_book' => 'Sorter innholdet i boken',
+    'books_empty_add_chapter' => 'Start på nytt kapittel',
+    'books_permissions_active' => 'Boktilganger er aktive',
+    'books_search_this' => 'Søk i boken',
+    'books_navigation' => 'Boknavigasjon',
+    'books_sort' => 'Sorter bokinnhold',
+    'books_sort_named' => 'Sorter boken :bookName',
+    'books_sort_name' => 'Sorter på navn',
+    'books_sort_created' => 'Sorter på opprettet dato',
+    'books_sort_updated' => 'Sorter på oppdatert dato',
+    'books_sort_chapters_first' => 'Kapitler først',
+    'books_sort_chapters_last' => 'Kapitler sist',
+    'books_sort_show_other' => 'Vis andre bøker',
+    'books_sort_save' => 'Lagre sortering',
+
+    // Chapters
+    'chapter' => 'Kapittel',
+    'chapters' => 'Kapitler',
+    'x_chapters' => ':count Kapittel|:count Kapitler',
+    'chapters_popular' => 'Populære kapittler',
+    'chapters_new' => 'Nytt kapittel',
+    'chapters_create' => 'Skriv nytt kapittel',
+    'chapters_delete' => 'Riv ut kapittel',
+    'chapters_delete_named' => 'Riv ut kapittelet :chapterName',
+    'chapters_delete_explain' => 'Du ønsker å rive ut kapittelet «:chapterName». Alle sidene vil bli flyttet ut av kapittelet og vil ligge direkte i boka.',
+    'chapters_delete_confirm' => 'Er du sikker på at du vil rive ut dette kapittelet?',
+    'chapters_edit' => 'Endre kapittel',
+    'chapters_edit_named' => 'Endre kapittelet :chapterName',
+    'chapters_save' => 'Lagre kapittel',
+    'chapters_move' => 'Flytt kapittel',
+    'chapters_move_named' => 'Flytt kapittelet :chapterName',
+    'chapter_move_success' => 'Kapittelet ble flyttet til :bookName',
+    'chapters_permissions' => 'Kapitteltilganger',
+    'chapters_empty' => 'Det finnes ingen sider i dette kapittelet.',
+    'chapters_permissions_active' => 'Kapitteltilganger er aktivert',
+    'chapters_permissions_success' => 'Kapitteltilgager er oppdatert',
+    'chapters_search_this' => 'Søk i dette kapittelet',
+
+    // Pages
+    'page' => 'Side',
+    'pages' => 'Sider',
+    'x_pages' => ':count side|:count sider',
+    'pages_popular' => 'Populære sider',
+    'pages_new' => 'Ny side',
+    'pages_attachments' => 'Vedlegg',
+    'pages_navigation' => 'Sidenavigasjon',
+    'pages_delete' => 'Riv ut side',
+    'pages_delete_named' => 'Riv ut siden :pageName',
+    'pages_delete_draft_named' => 'Kast sideutkast :pageName',
+    'pages_delete_draft' => 'Kast sideutkast',
+    'pages_delete_success' => 'Siden er revet ut',
+    'pages_delete_draft_success' => 'Sideutkast er kastet',
+    'pages_delete_confirm' => 'Er du sikker på at du vil rive ut siden?',
+    'pages_delete_draft_confirm' => 'Er du sikker på at du vil forkaste utkastet?',
+    'pages_editing_named' => 'Endrer :pageName',
+    'pages_edit_draft_options' => 'Utkastsalternativer',
+    'pages_edit_save_draft' => 'Lagre utkast',
+    'pages_edit_draft' => 'Endre utkast',
+    'pages_editing_draft' => 'Redigerer utkast',
+    'pages_editing_page' => 'Redigerer side',
+    'pages_edit_draft_save_at' => 'Ukast lagret under ',
+    'pages_edit_delete_draft' => 'Forkast utkast',
+    'pages_edit_discard_draft' => 'Gi opp utkast',
+    'pages_edit_set_changelog' => 'Angi endringslogg',
+    'pages_edit_enter_changelog_desc' => 'Gi en kort beskrivelse av endringene dine',
+    'pages_edit_enter_changelog' => 'Se endringslogg',
+    'pages_save' => 'Lagre side',
+    'pages_title' => 'Sidetittel',
+    'pages_name' => 'Sidenavn',
+    'pages_md_editor' => 'Tekstbehandler',
+    'pages_md_preview' => 'Forhåndsvisning',
+    'pages_md_insert_image' => 'Lim inn bilde',
+    'pages_md_insert_link' => 'Lim in lenke',
+    'pages_md_insert_drawing' => 'Lim inn tegning',
+    'pages_not_in_chapter' => 'Siden tilhører ingen kapittel',
+    'pages_move' => 'Flytt side',
+    'pages_move_success' => 'Siden ble flyttet til ":parentName"',
+    'pages_copy' => 'Kopier side',
+    'pages_copy_desination' => 'Destinasjon',
+    'pages_copy_success' => 'Siden ble flyttet',
+    'pages_permissions' => 'Sidetilganger',
+    'pages_permissions_success' => 'Sidens tilganger ble endret',
+    'pages_revision' => 'Revisjon',
+    'pages_revisions' => 'Sidens revisjoner',
+    'pages_revisions_named' => 'Revisjoner for :pageName',
+    'pages_revision_named' => 'Revisjoner for :pageName',
+    'pages_revision_restored_from' => 'Gjenopprettet fra #:id; :summary',
+    'pages_revisions_created_by' => 'Skrevet av',
+    'pages_revisions_date' => 'Revideringsdato',
+    'pages_revisions_number' => '#',
+    'pages_revisions_numbered' => 'Revisjon #:id',
+    'pages_revisions_numbered_changes' => 'Endringer på revisjon #:id',
+    'pages_revisions_changelog' => 'Endringslogg',
+    'pages_revisions_changes' => 'Endringer',
+    'pages_revisions_current' => 'Siste versjon',
+    'pages_revisions_preview' => 'Forhåndsvisning',
+    'pages_revisions_restore' => 'Gjenopprett',
+    'pages_revisions_none' => 'Denne siden har ingen revisjoner',
+    'pages_copy_link' => 'Kopier lenke',
+    'pages_edit_content_link' => 'Endre innhold',
+    'pages_permissions_active' => 'Sidetilganger er aktive',
+    'pages_initial_revision' => 'Første publisering',
+    '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_edit_active' => [
+        'start_a' => ':count forfattere har begynt å endre denne siden.',
+        'start_b' => ':userName skriver på siden for øyeblikket',
+        'time_a' => 'siden sist siden ble oppdatert',
+        'time_b' => 'i løpet av de siste :minCount minuttene',
+        'message' => ':start :time. Prøv å ikke overskriv hverandres endringer!',
+    ],
+    'pages_draft_discarded' => 'Forkastet, viser nå siste endringer fra siden slik den er lagret.',
+    'pages_specific' => 'Bestemt side',
+    'pages_is_template' => 'Sidemal',
+
+    // Editor Sidebar
+    'page_tags' => 'Sidemerker',
+    'chapter_tags' => 'Kapittelmerker',
+    'book_tags' => 'Bokmerker',
+    'shelf_tags' => 'Hyllemerker',
+    'tag' => 'Merke',
+    'tags' =>  'Merker',
+    'tag_name' =>  'Merketittel',
+    'tag_value' => 'Merkeverdi (Valgfritt)',
+    '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',
+    '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.',
+    'attachments_items' => 'Vedlegg',
+    'attachments_upload' => 'Last opp vedlegg',
+    'attachments_link' => 'Fest lenke',
+    'attachments_set_link' => 'Angi lenke',
+    'attachments_delete' => 'Er du sikker på at du vil fjerne vedlegget?',
+    'attachments_dropzone' => 'Dra og slipp eller trykk her for å feste vedlegg',
+    'attachments_no_files' => 'Ingen vedlegg er lastet opp',
+    'attachments_explain_link' => 'Du kan feste lenker til denne. Det kan være henvisning til andre sider, bøker etc. eller lenker fra nettet.',
+    'attachments_link_name' => 'Lenkenavn',
+    'attachment_link' => 'Vedleggslenke',
+    'attachments_link_url' => 'Lenke til vedlegg',
+    'attachments_link_url_hint' => 'Adresse til lenke eller vedlegg',
+    'attach' => 'Fest',
+    'attachments_insert_link' => 'Fest vedleggslenke',
+    'attachments_edit_file' => 'Endre vedlegg',
+    'attachments_edit_file_name' => 'Vedleggsnavn',
+    'attachments_edit_drop_upload' => 'Dra og slipp eller trykk her for å oppdatere eller overskrive',
+    'attachments_order_updated' => 'Vedleggssortering endret',
+    'attachments_updated_success' => 'Vedleggsdetaljer endret',
+    'attachments_deleted' => 'Vedlegg fjernet',
+    'attachments_file_uploaded' => 'Vedlegg ble lastet opp',
+    'attachments_file_updated' => 'Vedlegget ble oppdatert',
+    'attachments_link_attached' => 'Lenken ble festet til siden',
+    'templates' => 'Maler',
+    'templates_set_as_template' => 'Siden er en mal',
+    'templates_explain_set_as_template' => 'Du kan angi denne siden som en mal slik at innholdet kan brukes når du oppretter andre sider. Andre brukere vil kunne bruke denne malen hvis de har visningstillatelser for denne siden.',
+    'templates_replace_content' => 'Bytt sideinnhold',
+    'templates_append_content' => 'Legg til neders på siden',
+    'templates_prepend_content' => 'Legg til øverst på siden',
+
+    // Profile View
+    'profile_user_for_x' => 'Medlem i :time',
+    'profile_created_content' => 'Har skrevet',
+    'profile_not_created_pages' => ':userName har ikke forfattet noen sider',
+    'profile_not_created_chapters' => ':userName har ikke opprettet noen kapitler',
+    'profile_not_created_books' => ':userName har ikke laget noen bøker',
+    'profile_not_created_shelves' => ':userName har ikke hengt opp noen hyller',
+
+    // Comments
+    'comment' => 'Kommentar',
+    'comments' => 'Kommentarer',
+    'comment_add' => 'Skriv kommentar',
+    'comment_placeholder' => 'Skriv en kommentar her',
+    'comment_count' => '{0} Ingen kommentarer|{1} 1 kommentar|[2,*] :count kommentarer',
+    'comment_save' => 'Publiser kommentar',
+    'comment_saving' => 'Publiserer ...',
+    'comment_deleting' => 'Fjerner...',
+    'comment_new' => 'Ny kommentar',
+    'comment_created' => 'kommenterte :createDiff',
+    'comment_updated' => 'Oppdatert :updateDiff av :username',
+    'comment_deleted_success' => 'Kommentar fjernet',
+    'comment_created_success' => 'Kommentar skrevet',
+    'comment_updated_success' => 'Kommentar endret',
+    'comment_delete_confirm' => 'Er du sikker på at du vil fjerne kommentaren?',
+    'comment_in_reply_to' => 'Som svar til :commentId',
+
+    // Revision
+    '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.'
+];
diff --git a/resources/lang/nb/errors.php b/resources/lang/nb/errors.php
new file mode 100644 (file)
index 0000000..4713be3
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Text shown in error messaging.
+ */
+return [
+
+    // Permissions
+    'permission' => 'Du har ikke tilgang til å se denne siden.',
+    'permissionJson' => 'Du har ikke tilgang til å utføre denne handlingen.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'En konto med :email finnes allerede, men har andre detaljer.',
+    'email_already_confirmed' => 'E-posten er allerede bekreftet, du kan forsøke å logge inn.',
+    'email_confirmation_invalid' => 'Denne bekreftelseskoden er allerede benyttet eller utgått. Prøv å registrere på nytt.',
+    'email_confirmation_expired' => 'Bekreftelseskoden er allerede utgått, en ny e-post er sendt.',
+    'email_confirmation_awaiting' => 'Du må bekrefte e-posten for denne kontoen.',
+    'ldap_fail_anonymous' => 'LDAP kan ikke benyttes med anonym tilgang for denne tjeneren.',
+    'ldap_fail_authed' => 'LDAP tilgang feilet med angitt DN',
+    'ldap_extension_not_installed' => 'LDAP PHP modulen er ikke installert.',
+    'ldap_cannot_connect' => 'Klarer ikke koble til LDAP på denne adressen',
+    'saml_already_logged_in' => 'Allerede logget inn',
+    'saml_user_not_registered' => 'Kontoen med navn :name er ikke registert, registrering er også deaktivert.',
+    '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.',
+    '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.',
+    'social_account_email_in_use' => 'E-posten :email er allerede i bruk. Har du allerede en konto hos :socialAccount kan dette angis fra profilsiden din.',
+    'social_account_existing' => 'Denne :socialAccount er allerede koblet til din konto.',
+    'social_account_already_used_existing' => 'Denne :socialAccount kontoen brukes allerede av noen andre.',
+    'social_account_not_used' => 'Denne :socialAccount konten er ikke koblet til noen konto, angi denne i profilinnstillingene dine. ',
+    'social_account_register_instructions' => 'Har du ikke en konto her ennå, kan du benytte :socialAccount alternativet for å registrere deg.',
+    'social_driver_not_found' => 'Autentiseringstjeneste fra sosiale medier er ikke installert',
+    'social_driver_not_configured' => 'Dine :socialAccount innstilliner er ikke angitt.',
+    'invite_token_expired' => 'Invitasjonslenken har utgått, du kan forsøke å be om nytt passord istede.',
+
+    // System
+    'path_not_writable' => 'Filstien :filePath aksepterer ikke filer, du må sjekke filstitilganger i systemet.',
+    'cannot_get_image_from_url' => 'Kan ikke hente bilde fra :url',
+    'cannot_create_thumbs' => 'Kan ikke opprette miniatyrbilder. GD PHP er ikke installert.',
+    'server_upload_limit' => 'Vedlegget er for stort, forsøk med et mindre vedlegg.',
+    'uploaded'  => 'Tjenesten aksepterer ikke vedlegg som er så stor.',
+    'image_upload_error' => 'Bildet kunne ikke lastes opp, forsøk igjen.',
+    'image_upload_type_error' => 'Bildeformatet støttes ikke, forsøk med et annet format.',
+    'file_upload_timeout' => 'Opplastingen gikk ut på tid.',
+
+    // Attachments
+    'attachment_not_found' => 'Vedlegget ble ikke funnet',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Kunne ikke lagre utkastet, forsikre deg om at du er tilkoblet tjeneren (Har du nettilgang?)',
+    'page_custom_home_deletion' => 'Kan ikke slette en side som er satt som forside.',
+
+    // Entities
+    'entity_not_found' => 'Entitet ble ikke funnet',
+    'bookshelf_not_found' => 'Bokhyllen ble ikke funnet',
+    'book_not_found' => 'Boken ble ikke funnet',
+    'page_not_found' => 'Siden ble ikke funnet',
+    'chapter_not_found' => 'Kapittel ble ikke funnet',
+    'selected_book_not_found' => 'Den valgte boken eksisterer ikke',
+    'selected_book_chapter_not_found' => 'Den valgte boken eller kapittelet eksisterer ikke',
+    'guests_cannot_save_drafts' => 'Gjester kan ikke lagre utkast',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Du kan ikke kaste ut den eneste administratoren',
+    'users_cannot_delete_guest' => 'Du kan ikke slette gjestebrukeren (Du kan deaktivere offentlig visning istede)',
+
+    // Roles
+    'role_cannot_be_edited' => 'Denne rollen kan ikke endres',
+    'role_system_cannot_be_deleted' => 'Denne systemrollen kan ikke slettes',
+    'role_registration_default_cannot_delete' => 'Du kan ikke slette en rolle som er satt som registreringsrolle (rollen nye kontoer får når de registrerer seg)',
+    'role_cannot_remove_only_admin' => 'Denne brukeren er den eneste brukeren som er tildelt administratorrollen. Tilordne administratorrollen til en annen bruker før du prøver å fjerne den her.',
+
+    // Comments
+    'comment_list' => 'Det oppstod en feil under henting av kommentarene.',
+    'cannot_add_comment_to_draft' => 'Du kan ikke legge til kommentarer i et utkast.',
+    'comment_add' => 'Det oppsto en feil da kommentaren skulle legges til / oppdateres.',
+    'comment_delete' => 'Det oppstod en feil under sletting av kommentaren.',
+    'empty_comment' => 'Kan ikke legge til en tom kommentar.',
+
+    // Error pages
+    '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',
+    'back_soon' => 'Den vil snart komme tilbake.',
+
+    // API errors
+    'api_no_authorization_found' => 'Ingen autorisasjonstoken ble funnet på forespørselen',
+    'api_bad_authorization_format' => 'Det ble funnet et autorisasjonstoken på forespørselen, men formatet virket feil',
+    'api_user_token_not_found' => 'Ingen samsvarende API-token ble funnet for det angitte autorisasjonstokenet',
+    'api_incorrect_token_secret' => 'Hemmeligheten som er gitt for det gitte brukte API-tokenet er feil',
+    'api_user_no_api_permission' => 'Eieren av det brukte API-tokenet har ikke tillatelse til å ringe API-samtaler',
+    'api_user_token_expired' => 'Autorisasjonstokenet som er brukt, har utløpt',
+
+    // Settings & Maintenance
+    'maintenance_test_email_failure' => 'Feil kastet når du sendte en test-e-post:',
+
+];
diff --git a/resources/lang/nb/pagination.php b/resources/lang/nb/pagination.php
new file mode 100644 (file)
index 0000000..d910da1
--- /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; Forrige',
+    'next'     => 'Neste &raquo;',
+
+];
diff --git a/resources/lang/nb/passwords.php b/resources/lang/nb/passwords.php
new file mode 100644 (file)
index 0000000..8c3215b
--- /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' => 'Passord må inneholde minst åtte tegn og samsvarer med bekreftelsen.',
+    'user' => "Vi finner ikke en bruker med den e-postadressen.",
+    'token' => 'Passordet for tilbakestilling av passord er ugyldig for denne e-postadressen.',
+    'sent' => 'Vi har sendt e-postadressen til tilbakestilling av passordet ditt!',
+    'reset' => 'Passordet ditt har blitt tilbakestilt!',
+
+];
diff --git a/resources/lang/nb/settings.php b/resources/lang/nb/settings.php
new file mode 100644 (file)
index 0000000..cfa82f8
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+/**
+ * Settings text strings
+ * Contains all text strings used in the general settings sections of BookStack
+ * including users and roles.
+ */
+return [
+
+    // Common Messages
+    'settings' => 'Innstillinger',
+    'settings_save' => 'Lagre innstillinger',
+    'settings_save_success' => 'Innstillinger lagret',
+
+    // App Settings
+    'app_customization' => 'Tilpassing',
+    'app_features_security' => 'Funksjoner og sikkerhet',
+    'app_name' => 'Applikasjonsnavn',
+    'app_name_desc' => 'Dette navnet vises i overskriften og i alle e-postmeldinger som sendes av systemet.',
+    'app_name_header' => 'Vis navn i topptekst',
+    'app_public_access' => 'Offentlig tilgang',
+    'app_public_access_desc' => 'Hvis du aktiverer dette alternativet, kan besøkende, som ikke er logget på, få tilgang til innhold i din BookStack-forekomst.',
+    'app_public_access_desc_guest' => 'Tilgang for offentlige besøkende kan kontrolleres gjennom "Gjest" -brukeren.',
+    'app_public_access_toggle' => 'Tillat offentlig tilgang',
+    'app_public_viewing' => 'Tillat offentlig visning?',
+    'app_secure_images' => 'Høyere sikkerhet på bildeopplastinger',
+    'app_secure_images_toggle' => 'Enable høyere sikkerhet på bildeopplastinger',
+    'app_secure_images_desc' => 'Av ytelsesgrunner er alle bilder offentlige. Dette alternativet legger til en tilfeldig streng som er vanskelig å gjette foran bildets nettadresser. Forsikre deg om at katalogindekser ikke er aktivert for å forhindre enkel tilgang.',
+    'app_editor' => 'Tekstbehandler',
+    'app_editor_desc' => 'Velg hvilken tekstbehandler som skal brukes av alle brukere til å redigere sider.',
+    'app_custom_html' => 'Tilpasset HTML-hodeinnhold',
+    'app_custom_html_desc' => 'Alt innhold som legges til her, blir satt inn i bunnen av <head> -delen på hver side. Dette er praktisk for å overstyre stiler eller legge til analysekode.',
+    'app_custom_html_disabled_notice' => 'Tilpasset HTML-hodeinnhold er deaktivert på denne innstillingssiden for å sikre at eventuelle endringer ødelegger noe, kan tilbakestilles.',
+    'app_logo' => 'Applikasjonslogo',
+    'app_logo_desc' => 'Dette bildet skal være 43 px høyt. <br> Store bilder blir nedskalert.',
+    'app_primary_color' => 'Applikasjonens primærfarge',
+    'app_primary_color_desc' => 'Angir primærfargen for applikasjonen inkludert banner, knapper og lenker.',
+    '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' => '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.',
+
+    // Color settings
+    'content_colors' => 'Innholdsfarger',
+    'content_colors_desc' => 'Angir farger for alle elementene i sideorganisasjonshierarkiet. Det anbefales å lese farger med en lignende lysstyrke som standardfargene for lesbarhet.',
+    'bookshelf_color' => 'Hyllefarge',
+    'book_color' => 'Bokfarge',
+    'chapter_color' => 'Kapittelfarge',
+    'page_color' => 'Sidefarge',
+    'page_draft_color' => 'Sideutkastsfarge',
+
+    // Registration Settings
+    'reg_settings' => 'Registrering',
+    'reg_enable' => 'Tillat registrering',
+    'reg_enable_toggle' => 'Tillat registrering',
+    'reg_enable_desc' => 'Når registrering er aktivert vil brukeren kunne registrere seg som applikasjonsbruker. Ved registrering får de en standard brukerrolle.',
+    'reg_default_role' => 'Standard brukerrolle etter registrering',
+    'reg_enable_external_warning' => 'Alternativet ovenfor ignoreres mens ekstern LDAP- eller SAML-autentisering er aktiv. Brukerkontoer for ikke-eksisterende medlemmer blir automatisk opprettet hvis autentisering mot det eksterne systemet i bruk lykkes.',
+    'reg_email_confirmation' => 'E-postbekreftelse',
+    'reg_email_confirmation_toggle' => 'Krev e-postbekreftelse',
+    'reg_confirm_email_desc' => 'Hvis domenebegrensning brukes, vil e-postbekreftelse være nødvendig, og dette alternativet vil bli ignorert.',
+    'reg_confirm_restrict_domain' => 'Domenebegrensning',
+    'reg_confirm_restrict_domain_desc' => 'Skriv inn en kommaseparert liste over e-postdomener du vil begrense registreringen til. Brukerne vil bli sendt en e-post for å bekrefte adressen deres før de får lov til å kommunisere med applikasjonen. <br> Vær oppmerksom på at brukere vil kunne endre e-postadressene sine etter vellykket registrering.',
+    'reg_confirm_restrict_domain_placeholder' => 'Ingen begrensninger er satt',
+
+    // 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_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?',
+    'maint_image_cleanup_success' => ':count potensielt ubrukte bilder funnet og slettet!',
+    'maint_image_cleanup_nothing_found' => 'Ingen ubrukte bilder funnet, ingenting slettet!',
+    'maint_send_test_email' => 'Send en test-e-post',
+    'maint_send_test_email_desc' => 'Dette sender en test-e-post til din e-postadresse som er angitt i profilen din.',
+    'maint_send_test_email_run' => 'Send en test-e-post',
+    'maint_send_test_email_success' => 'Send en test-e-post til :address',
+    'maint_send_test_email_mail_subject' => 'Test-e-post',
+    'maint_send_test_email_mail_greeting' => 'E-postsending ser ut til å fungere!',
+    'maint_send_test_email_mail_text' => 'Gratulerer! Da du mottok dette e-postvarselet, ser det ut til at e-postinnstillingene dine er konfigurert riktig.',
+    'maint_recycle_bin_desc' => 'Slettede hyller, bøker, kapitler og sider kastes i papirkurven så de kan bli gjenopprettet eller slettet permanent. Eldre utgaver i papirkurven kan slettes automatisk etter en stund, avhengig av systemkonfigurasjonen.',
+    'maint_recycle_bin_open' => 'Åpne papirkurven',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Gjenopprett',
+    'recycle_bin_contents_empty' => 'Papirkurven er for øyeblikket tom',
+    'recycle_bin_empty' => 'Tøm papirkurven',
+    'recycle_bin_empty_confirm' => 'Dette vil slette alle elementene i papirkurven permanent. Dette inkluderer innhold i hvert element. Er du sikker på at du vil tømme papirkurven?',
+    'recycle_bin_destroy_confirm' => 'Denne handlingen vil permanent slette dette elementet og alle dets underelementer fra systemet, som beskrevet nedenfor. Du vil ikke kunne gjenopprette dette innholdet med mindre du har en tidligere sikkerhetskopi av databasen. Er du sikker på at du vil fortsette?',
+    'recycle_bin_destroy_list' => 'Elementer som skal slettes',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Revisjonslogg',
+    'audit_desc' => 'Denne revisjonsloggen viser en liste over aktiviteter som spores i systemet. Denne listen er ufiltrert i motsetning til lignende aktivitetslister i systemet der tillatelsesfiltre brukes.',
+    'audit_event_filter' => 'Hendelsesfilter',
+    'audit_event_filter_no_filter' => 'Ingen filter',
+    'audit_deleted_item' => 'Slettet ting',
+    'audit_deleted_item_name' => 'Navn: :name',
+    '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',
+
+    // Role Settings
+    'roles' => 'Roller',
+    'role_user_roles' => 'Kontoroller',
+    'role_create' => 'Opprett ny rolle',
+    'role_create_success' => 'Rolle opprettet',
+    'role_delete' => 'Rolle slettet',
+    'role_delete_confirm' => 'Dette vil slette rollen «:roleName».',
+    'role_delete_users_assigned' => 'Denne rollen har :userCount kontoer koblet opp mot seg. Velg hvilke rolle du vil flytte disse til.',
+    'role_delete_no_migration' => "Ikke flytt kontoer",
+    'role_delete_sure' => 'Er du sikker på at du vil slette rollen?',
+    'role_delete_success' => 'Rollen ble slettet',
+    'role_edit' => 'Endre rolle',
+    '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',
+    'role_manage_roles' => 'Behandle roller og rolletilganger',
+    'role_manage_entity_permissions' => 'Behandle bok-, kapittel- og sidetilganger',
+    'role_manage_own_entity_permissions' => 'Behandle tilganger på egne verk',
+    '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.',
+    'role_asset_admins' => 'Administratorer får automatisk tilgang til alt innhold, men disse alternativene kan vise eller skjule UI-alternativer.',
+    'role_all' => 'Alle',
+    'role_own' => 'Egne',
+    'role_controlled_by_asset' => 'Kontrollert av eiendelen de er lastet opp til',
+    'role_save' => 'Lagre rolle',
+    'role_update_success' => 'Rollen ble oppdatert',
+    'role_users' => 'Kontoholdere med denne rollen',
+    'role_users_none' => 'Ingen kontoholdere er gitt denne rollen',
+
+    // Users
+    'users' => 'Brukere',
+    'user_profile' => 'Profil',
+    'users_add_new' => 'Register ny konto',
+    'users_search' => 'Søk i kontoer',
+    'users_latest_activity' => 'Siste aktivitet',
+    'users_details' => 'Kontodetaljer',
+    'users_details_desc' => 'Angi et visningsnavn og en e-postadresse for denne kontoholderen. E-postadressen vil bli brukt til å logge på applikasjonen.',
+    'users_details_desc_no_email' => 'Angi et visningsnavn for denne kontoholderen slik at andre kan gjenkjenne dem.',
+    '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_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',
+    'users_external_auth_id_desc' => 'Dette er ID-en som brukes til å matche denne kontoholderen når de kommuniserer med det eksterne autentiseringssystemet.',
+    'users_password_warning' => 'Fyll bare ut nedenfor hvis du vil endre passordet ditt.',
+    'users_system_public' => 'Denne brukeren representerer alle gjester som besøker appliaksjonen din. Den kan ikke brukes til å logge på, men tildeles automatisk.',
+    'users_delete' => 'Slett konto',
+    'users_delete_named' => 'Slett kontoen :userName',
+    'users_delete_warning' => 'Dette vil fullstendig slette denne brukeren med navnet «:userName» fra systemet.',
+    'users_delete_confirm' => 'Er du sikker på at du vil slette denne kontoen?',
+    'users_migrate_ownership' => 'Overfør eierskap',
+    'users_migrate_ownership_desc' => 'Velg en bruker her, som du ønsker skal ta eierskap over alle elementene som er eid av denne brukeren.',
+    'users_none_selected' => 'Ingen bruker valgt',
+    'users_delete_success' => 'Konto slettet',
+    'users_edit' => 'Rediger konto',
+    'users_edit_profile' => 'Rediger profil',
+    'users_edit_success' => 'Kontoen ble oppdatert',
+    'users_avatar' => 'Kontobilde',
+    'users_avatar_desc' => 'Velg et bilde for å representere denne kontoholderen. Dette skal være omtrent 256px kvadrat.',
+    'users_preferred_language' => 'Foretrukket språk',
+    'users_preferred_language_desc' => 'Dette alternativet vil endre språket som brukes til brukergrensesnittet til applikasjonen. Dette påvirker ikke noe brukeropprettet innhold.',
+    'users_social_accounts' => 'Sosiale kontoer',
+    'users_social_accounts_info' => 'Her kan du koble andre kontoer for raskere og enklere pålogging. Hvis du frakobler en konto her, tilbakekaller ikke dette tidligere autorisert tilgang. Tilbakekall tilgang fra profilinnstillingene dine på den tilkoblede sosiale kontoen.',
+    'users_social_connect' => 'Koble til konto',
+    'users_social_disconnect' => 'Koble fra konto',
+    'users_social_connected' => ':socialAccount ble lagt til din konto.',
+    'users_social_disconnected' => ':socialAccount ble koblet fra din konto.',
+    'users_api_tokens' => 'API-nøkler',
+    'users_api_tokens_none' => 'Ingen API-nøkler finnes for denne kontoen',
+    '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',
+    'user_api_token_name' => 'Navn',
+    'user_api_token_name_desc' => 'Gi nøkkelen et lesbart navn som en fremtidig påminnelse om det tiltenkte formålet.',
+    'user_api_token_expiry' => 'Utløpsdato',
+    'user_api_token_expiry_desc' => 'Angi en dato da denne nøkkelen utløper. Etter denne datoen vil forespørsler som er gjort med denne nøkkelen ikke lenger fungere. Å la dette feltet stå tomt vil sette utløpsdato 100 år inn i fremtiden.',
+    'user_api_token_create_secret_message' => 'Umiddelbart etter å ha opprettet denne nøkkelen vil en identifikator og hemmelighet bli generert og vist. Hemmeligheten vil bare vises en gang, så husk å kopiere verdien til et trygt sted før du fortsetter.',
+    'user_api_token_create_success' => 'API-nøkkel ble opprettet',
+    'user_api_token_update_success' => 'API-nøkkel ble oppdatert',
+    'user_api_token' => 'API-nøkkel',
+    'user_api_token_id' => 'Identifikator',
+    'user_api_token_id_desc' => 'Dette er en ikke-redigerbar systemgenerert identifikator for denne nøkkelen som må oppgis i API-forespørsler.',
+    'user_api_token_secret' => 'Hemmelighet',
+    'user_api_token_secret_desc' => 'Dette er en systemgenerert hemmelighet for denne nøkkelen som må leveres i API-forespørsler. Dette vises bare denne gangen, så kopier denne verdien til et trygt sted.',
+    'user_api_token_created' => 'Nøkkel opprettet :timeAgo',
+    'user_api_token_updated' => 'Nøkkel oppdatert :timeAgo',
+    'user_api_token_delete' => 'Slett nøkkel',
+    'user_api_token_delete_warning' => 'Dette vil slette API-nøkkelen \':tokenName\' fra systemet.',
+    'user_api_token_delete_confirm' => 'Sikker på at du vil slette nøkkelen?',
+    'user_api_token_delete_success' => 'API-nøkkelen ble slettet',
+
+    //! 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',
+        '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/nb/validation.php b/resources/lang/nb/validation.php
new file mode 100644 (file)
index 0000000..6846457
--- /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 må aksepteres.',
+    'active_url'           => ':attribute er ikke en godkjent URL.',
+    'after'                => ':attribute må være en dato etter :date.',
+    'alpha'                => ':attribute kan kun inneholde bokstaver.',
+    '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.',
+        'file'    => ':attribute må være mellom :min og :max kilobytes.',
+        'string'  => ':attribute må være mellom :min og :max tegn.',
+        'array'   => ':attribute må være mellom :min og :max ting.',
+    ],
+    'boolean'              => ':attribute feltet kan bare være sann eller falsk.',
+    'confirmed'            => ':attribute bekreftelsen samsvarer ikke.',
+    'date'                 => ':attribute er ikke en gyldig dato.',
+    'date_format'          => ':attribute samsvarer ikke med :format.',
+    'different'            => ':attribute og :other må være forskjellige.',
+    'digits'               => ':attribute må være :digits tall.',
+    'digits_between'       => ':attribute må være mellomg :min og :max tall.',
+    'email'                => ':attribute må være en gyldig e-post.',
+    'ends_with' => ':attribute må slutte med en av verdiene: :values',
+    'filled'               => ':attribute feltet er påkrevd.',
+    'gt'                   => [
+        'numeric' => ':attribute må være større enn :value.',
+        'file'    => ':attribute må være større enn :value kilobytes.',
+        'string'  => ':attribute må være større enn :value tegn.',
+        'array'   => ':attribute må ha mer en :value ting.',
+    ],
+    'gte'                  => [
+        'numeric' => ':attribute må være større enn eller lik :value.',
+        'file'    => ':attribute må være større enn eller lik :value kilobytes.',
+        'string'  => ':attribute må være større enn eller lik :value tegn.',
+        'array'   => ':attribute må ha :value eller flere ting.',
+    ],
+    'exists'               => 'Den valgte :attribute er ugyldig.',
+    'image'                => ':attribute må være et bilde.',
+    'image_extension'      => ':attribute må ha støttet formattype.',
+    'in'                   => 'Den valgte :attribute er ugyldig.',
+    'integer'              => ':attribute må være et heltall',
+    'ip'                   => ':attribute må være en gyldig IP adresse.',
+    'ipv4'                 => ':attribute må være en gyldig IPv4 adresse.',
+    'ipv6'                 => ':attribute må være en gyldig IPv6 adresse.',
+    'json'                 => ':attribute må være en gyldig JSON tekststreng.',
+    'lt'                   => [
+        'numeric' => ':attribute må være mindre enn :value.',
+        'file'    => ':attribute må være mindre enn :value kilobytes.',
+        'string'  => ':attribute må være mindre enn :value tegn.',
+        'array'   => ':attribute må ha mindre enn :value ting.',
+    ],
+    'lte'                  => [
+        'numeric' => ':attribute må være mindre enn eller lik :value.',
+        'file'    => ':attribute må være mindre enn eller lik :value kilobytes.',
+        'string'  => ':attribute må være mindre enn eller lik :value characters.',
+        'array'   => ':attribute må ha mindre enn eller lik :value ting.',
+    ],
+    'max'                  => [
+        'numeric' => ':attribute kan ikke være større enn :max.',
+        'file'    => ':attribute kan ikke være større enn :max kilobytes.',
+        'string'  => ':attribute kan ikke være større enn :max tegn.',
+        'array'   => ':attribute kan ikke inneholde mer enn :max ting.',
+    ],
+    'mimes'                => ':attribute må være en fil av typen: :values.',
+    'min'                  => [
+        'numeric' => ':attribute må være på minst :min.',
+        'file'    => ':attribute må være på minst :min kilobytes.',
+        'string'  => ':attribute må være på minst :min tegn.',
+        'array'   => ':attribute må minst ha :min ting.',
+    ],
+    'not_in'               => 'Den valgte :attribute er ugyldig.',
+    'not_regex'            => ':attribute format er ugyldig.',
+    'numeric'              => ':attribute må være et nummer.',
+    'regex'                => ':attribute format er ugyldig.',
+    'required'             => ':attribute feltet er påkrevt.',
+    'required_if'          => ':attribute feltet er påkrevt når :other er :value.',
+    'required_with'        => ':attribute feltet er påkrevt når :values er tilgjengelig.',
+    'required_with_all'    => ':attribute feltet er påkrevt når :values er tilgjengelig',
+    'required_without'     => ':attribute feltet er påkrevt når :values ikke er tilgjengelig.',
+    'required_without_all' => ':attribute feltet er påkrevt når ingen av :values er tilgjengelig.',
+    'same'                 => ':attribute og :other må samsvare.',
+    'safe_url'             => 'Den angitte lenken kan være farlig.',
+    'size'                 => [
+        'numeric' => ':attribute må være :size.',
+        'file'    => ':attribute må være :size kilobytes.',
+        'string'  => ':attribute må være :size tegn.',
+        'array'   => ':attribute må inneholde :size ting.',
+    ],
+    '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.',
+
+    // Custom validation lines
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'passordbekreftelse er påkrevd',
+        ],
+    ],
+
+    // Custom validation attributes
+    'attributes' => [],
+];
index 76272888119a3127329f3cf986a49514bdb016be..f45ea074ad77d4ff5d59b1eb56c67ec32af1e065 100644 (file)
@@ -7,42 +7,51 @@ return [
 
     // Pages
     'page_create'                 => 'maakte pagina',
-    'page_create_notification'    => 'Pagina Succesvol Aangemaakt',
-    'page_update'                 => 'veranderde pagina',
-    'page_update_notification'    => 'Pagina Succesvol Bijgewerkt',
+    'page_create_notification'    => 'Pagina succesvol aangemaakt',
+    'page_update'                 => 'wijzigde pagina',
+    'page_update_notification'    => 'Pagina succesvol bijgewerkt',
     'page_delete'                 => 'verwijderde pagina',
-    'page_delete_notification'    => 'Pagina Succesvol Verwijderd',
+    'page_delete_notification'    => 'Pagina succesvol verwijderd',
     'page_restore'                => 'herstelde pagina',
-    'page_restore_notification'   => 'Pagina Succesvol Hersteld',
+    'page_restore_notification'   => 'Pagina succesvol hersteld',
     'page_move'                   => 'verplaatste pagina',
 
     // Chapters
     'chapter_create'              => 'maakte hoofdstuk',
-    'chapter_create_notification' => 'Hoofdstuk Succesvol Aangemaakt',
-    'chapter_update'              => 'veranderde hoofdstuk',
-    'chapter_update_notification' => 'Hoofdstuk Succesvol Bijgewerkt',
+    'chapter_create_notification' => 'Hoofdstuk succesvol aangemaakt',
+    'chapter_update'              => 'wijzigde hoofdstuk',
+    'chapter_update_notification' => 'Hoofdstuk succesvol bijgewerkt',
     'chapter_delete'              => 'verwijderde hoofdstuk',
-    'chapter_delete_notification' => 'Hoofdstuk Succesvol Verwijderd',
+    'chapter_delete_notification' => 'Hoofdstuk succesvol verwijderd',
     'chapter_move'                => 'verplaatste hoofdstuk',
 
     // Books
     'book_create'                 => 'maakte boek',
-    'book_create_notification'    => 'Boek Succesvol Aangemaakt',
-    'book_update'                 => 'veranderde boek',
-    'book_update_notification'    => 'Boek Succesvol Bijgewerkt',
+    'book_create_notification'    => 'Boek succesvol aangemaakt',
+    'book_update'                 => 'wijzigde boek',
+    'book_update_notification'    => 'Boek succesvol bijgewerkt',
     'book_delete'                 => 'verwijderde boek',
-    'book_delete_notification'    => 'Boek Succesvol Verwijderd',
+    'book_delete_notification'    => 'Boek succesvol verwijderd',
     'book_sort'                   => 'sorteerde boek',
-    'book_sort_notification'      => 'Boek Succesvol Gesorteerd',
+    'book_sort_notification'      => 'Boek succesvol gesorteerd',
 
     // Bookshelves
-    'bookshelf_create'            => 'maakte Boekenplank',
-    'bookshelf_create_notification'    => 'Boekenplank Succesvol Aangemaakt',
-    'bookshelf_update'                 => 'veranderde boekenplank',
-    'bookshelf_update_notification'    => 'Boekenplank Succesvol Bijgewerkt',
+    'bookshelf_create'            => 'maakte boekenplank',
+    'bookshelf_create_notification'    => 'Boekenplank succesvol aangemaakt',
+    'bookshelf_update'                 => 'wijzigde boekenplank',
+    'bookshelf_update_notification'    => 'Boekenplank succesvol bijgewerkt',
     'bookshelf_delete'                 => 'verwijderde boekenplank',
-    'bookshelf_delete_notification'    => 'Boekenplank Succesvol Verwijderd',
+    'bookshelf_delete_notification'    => 'Boekenplank succesvol verwijderd',
+
+    // 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 method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
 
     // Other
-    'commented_on'                => 'reactie op',
+    'commented_on'                => 'reageerde op',
+    'permissions_update'          => 'wijzigde permissies',
 ];
index ce0be87edfb2b201e640669591df021cfea536f7..f57b2ecc770750b0b2e341b6be4bfc8a551d133d 100644 (file)
@@ -7,7 +7,7 @@
 return [
 
     'failed' => 'Deze inloggegevens zijn niet bij ons bekend.',
-    'throttle' => 'Te veel loginpogingen! Probeer het opnieuw na :seconds seconden.',
+    'throttle' => 'Te veel login pogingen! Probeer het opnieuw na :seconds seconden.',
 
     // Login & Register
     'sign_up' => 'Registreren',
@@ -20,34 +20,34 @@ return [
     'username' => 'Gebruikersnaam',
     'email' => 'E-mail',
     'password' => 'Wachtwoord',
-    'password_confirm' => 'Wachtwoord Bevestigen',
+    'password_confirm' => 'Wachtwoord bevestigen',
     'password_hint' => 'Minimaal 8 tekens',
     'forgot_password' => 'Wachtwoord vergeten?',
     'remember_me' => 'Mij onthouden',
-    'ldap_email_hint' => 'Geef een email op waarmee je dit account wilt gebruiken.',
-    'create_account' => 'Account Aanmaken',
+    'ldap_email_hint' => 'Geef een emailadres op voor dit account.',
+    'create_account' => 'Account aanmaken',
     'already_have_account' => 'Heb je al een account?',
     'dont_have_account' => 'Nog geen account?',
     'social_login' => 'Aanmelden via een sociaal netwerk',
-    'social_registration' => 'Social Registratie',
-    'social_registration_text' => 'Registreer en log in met een andere dienst.',
+    'social_registration' => 'Social registratie',
+    'social_registration_text' => 'Registreer en log in met een andere service.',
 
     'register_thanks' => 'Bedankt voor het registreren!',
     'register_confirm' => 'Controleer je e-mail en bevestig je registratie om in te loggen op :appName.',
     'registrations_disabled' => 'Registratie is momenteel niet mogelijk',
     'registration_email_domain_invalid' => 'Dit e-maildomein is niet toegestaan',
-    'register_success' => 'Bedankt voor het inloggen. Je bent ook geregistreerd.',
+    'register_success' => 'Bedankt voor het aanmelden! Je bent nu geregistreerd en aangemeld.',
 
 
     // Password Reset
-    'reset_password' => 'Wachtwoord Herstellen',
+    'reset_password' => 'Wachtwoord herstellen',
     'reset_password_send_instructions' => 'Geef je e-mail en we sturen je een link om je wachtwoord te herstellen',
-    'reset_password_send_button' => 'Link Sturen',
+    'reset_password_send_button' => 'Link sturen',
     'reset_password_sent' => 'Een link om het wachtwoord te resetten zal verstuurd worden naar :email als dat e-mailadres in het systeem gevonden is.',
     'reset_password_success' => 'Je wachtwoord is succesvol hersteld.',
     'email_reset_subject' => 'Herstel je wachtwoord van :appName',
-    'email_reset_text' => 'Je ontvangt deze e-mail zodat je je wachtwoord kunt herstellen.',
-    'email_reset_not_requested' => 'Als je jouw wachtwoord niet wilt wijzigen, doe dan niets.',
+    '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
@@ -56,14 +56,14 @@ return [
     '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 bevestigt!',
+    'email_confirm_success' => 'Je e-mailadres is bevestigd!',
     'email_confirm_resent' => 'De bevestigingse-mails is opnieuw verzonden. Controleer je inbox.',
 
-    'email_not_confirmed' => 'E-mail nog niet bevestigd',
+    'email_not_confirmed' => 'E-mailadres nog niet bevestigd',
     'email_not_confirmed_text' => 'Je e-mailadres is nog niet bevestigd.',
     'email_not_confirmed_click_link' => 'Klik op de link in de e-mail die vlak na je registratie is verstuurd.',
     'email_not_confirmed_resend' => 'Als je deze e-mail niet kunt vinden kun je deze met onderstaande formulier opnieuw verzenden.',
-    'email_not_confirmed_resend_button' => 'Bevestigingsmail Opnieuw Verzenden',
+    'email_not_confirmed_resend_button' => 'Bevestigingsmail opnieuw verzenden',
 
     // User Invite
     'user_invite_email_subject' => 'Je bent uitgenodigd voor :appName!',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Wachtwoord ingesteld, je hebt nu toegang tot :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.',
 ];
\ No newline at end of file
index f228fa6c47e0f2f9a9b3f92f7e02633b2481b5a9..c53df6f2c1e5867265f934fd905c1043684d7c54 100644 (file)
@@ -11,7 +11,7 @@ return [
     'save' => 'Opslaan',
     'continue' => 'Doorgaan',
     'select' => 'Kies',
-    'toggle_all' => 'Toggle Alles',
+    'toggle_all' => 'Toggle alles',
     'more' => 'Meer',
 
     // Form Labels
@@ -19,12 +19,12 @@ return [
     'description' => 'Beschrijving',
     'role' => 'Rol',
     'cover_image' => 'Omslagfoto',
-    'cover_image_description' => 'Deze afbeelding moet ongeveer 300x170px zijn.',
+    'cover_image_description' => 'Deze afbeelding moet ongeveer 440x250px zijn.',
     
     // Actions
     'actions' => 'Acties',
     'view' => 'Bekijk',
-    'view_all' => 'Bekijk Alle',
+    'view_all' => 'Bekijk alle',
     'create' => 'Aanmaken',
     'update' => 'Bijwerken',
     'edit' => 'Bewerk',
@@ -33,47 +33,63 @@ return [
     'copy' => 'Kopiëren',
     'reply' => 'Beantwoorden',
     'delete' => 'Verwijder',
+    'delete_confirm' => 'Verwijdering bevestigen',
     'search' => 'Zoek',
     'search_clear' => 'Zoekopdracht wissen',
     'reset' => 'Resetten',
     'remove' => 'Verwijderen',
     'add' => 'Toevoegen',
+    'configure' => 'Configure',
     'fullscreen' => 'Volledig scherm',
+    'favourite' => 'Favoriet',
+    'unfavourite' => 'Verwijderen uit favoriet',
+    'next' => 'Volgende',
+    'previous' => 'Vorige',
 
     // Sort Options
     'sort_options' => 'Sorteeropties',
-    'sort_direction_toggle' => 'Sorteer richting',
+    'sort_direction_toggle' => 'Sorteerrichting',
     'sort_ascending' => 'Sorteer oplopend',
-    'sort_descending' => 'Sorteer teruglopend',
+    'sort_descending' => 'Sorteer aflopend',
     'sort_name' => 'Naam',
+    'sort_default' => 'Standaard',
     'sort_created_at' => 'Aanmaakdatum',
     'sort_updated_at' => 'Gewijzigd op',
 
     // Misc
     'deleted_user' => 'Verwijderde gebruiker',
-    'no_activity' => 'Geen activiteiten',
+    'no_activity' => 'Geen activiteit om weer te geven',
     'no_items' => 'Geen items beschikbaar',
     'back_to_top' => 'Terug naar boven',
-    'toggle_details' => 'Details Weergeven',
-    'toggle_thumbnails' => 'Thumbnails Weergeven',
+    'skip_to_main_content' => 'Direct naar de hoofdinhoud',
+    'toggle_details' => 'Details weergeven',
+    'toggle_thumbnails' => 'Thumbnails weergeven',
     'details' => 'Details',
     'grid_view' => 'Grid weergave',
-    'list_view' => 'Lijst weergave',
+    'list_view' => 'Lijstweergave',
     'default' => 'Standaard',
     'breadcrumb' => 'Kruimelpad',
 
     // Header
+    'header_menu_expand' => 'Header menu uitvouwen',
     'profile_menu' => 'Profiel menu',
-    'view_profile' => 'Profiel Weergeven',
-    'edit_profile' => 'Profiel Bewerken',
-    'dark_mode' => 'Donkere Modus',
-    'light_mode' => 'Lichte Modus',
+    'view_profile' => 'Profiel weergeven',
+    'edit_profile' => 'Profiel bewerken',
+    'dark_mode' => 'Donkere modus',
+    'light_mode' => 'Lichte modus',
 
     // Layout tabs
     'tab_info' => 'Info',
+    'tab_info_label' => 'Tabblad: Toon secundaire informatie',
     'tab_content' => 'Inhoud',
+    'tab_content_label' => 'Tabblad: Toon primaire inhoud',
 
     // Email Content
-    'email_action_help' => 'Als je de knop ":actionText" niet werkt, kopieer en plak de onderstaande URL in je web browser:',
+    'email_action_help' => 'Als je de knop ":actionText" niet werkt, kopieër en plak de onderstaande URL in je web browser:',
     'email_rights' => 'Alle rechten voorbehouden',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacybeleid',
+    'terms_of_service' => 'Algemene voorwaarden',
 ];
index 4083b57bebcc67ee04870aca22702892b67271d0..0cddef7c79cc8dc435dc94b177e80d2ad534a6dd 100644 (file)
@@ -5,21 +5,21 @@
 return [
 
     // Image Manager
-    'image_select' => 'Afbeelding selecteren',
+    'image_select' => 'Selecteer afbeelding',
     'image_all' => 'Alles',
     'image_all_title' => 'Alle afbeeldingen weergeven',
     'image_book_title' => 'Afbeeldingen van dit boek weergeven',
     'image_page_title' => 'Afbeeldingen van deze pagina weergeven',
     'image_search_hint' => 'Zoek op afbeeldingsnaam',
     'image_uploaded' => 'Geüpload :uploadedDate',
-    'image_load_more' => 'Meer Laden',
+    'image_load_more' => 'Meer laden',
     'image_image_name' => 'Afbeeldingsnaam',
     'image_delete_used' => 'Deze afbeeldingen is op onderstaande pagina\'s in gebruik.',
-    'image_delete_confirm' => 'Klik opnieuw op verwijderen om de afbeelding echt te verwijderen.',
-    'image_select_image' => 'Kies Afbeelding',
+    'image_delete_confirm_text' => 'Weet u zeker dat u deze afbeelding wilt verwijderen?',
+    'image_select_image' => 'Kies afbeelding',
     'image_dropzone' => 'Sleep afbeeldingen hier of klik hier om te uploaden',
-    'images_deleted' => 'Verwijderde Afbeeldingen',
-    'image_preview' => 'Afbeelding Voorbeeld',
+    'images_deleted' => 'Verwijderde afbeeldingen',
+    'image_preview' => 'Afbeelding voorbeeld',
     'image_upload_success' => 'Afbeelding succesvol geüpload',
     'image_update_success' => 'Afbeeldingsdetails succesvol verwijderd',
     'image_delete_success' => 'Afbeelding succesvol verwijderd',
@@ -27,7 +27,8 @@ return [
 
     // Code Editor
     'code_editor' => 'Code invoegen',
-    'code_language' => 'Code taal',
+    'code_language' => 'Codetaal',
     'code_content' => 'Code',
+    'code_session_history' => 'Sessie geschiedenis',
     'code_save' => 'Sla code op',
 ];
index a88be77119bd7f4e06f82a0759fc1351da6cec35..56ef9a07a8a35064af7e29c981fb6dc12fa92427 100644 (file)
@@ -6,63 +6,70 @@
 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',
-    'entity_select' => 'Entiteit Selecteren',
+    'meta_owned_name' => 'Eigendom van :user',
+    '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' => 'Contained Web File',
-    'export_pdf' => 'PDF File',
-    'export_text' => 'Plain Text File',
+    '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',
     'search_more' => 'Meer resultaten',
-    'search_filters' => 'Zoek filters',
-    'search_content_type' => 'Content Type',
-    'search_exact_matches' => 'Exacte Matches',
+    'search_advanced' => 'Uitgebreid zoeken',
+    'search_terms' => 'Zoektermen',
+    'search_content_type' => 'Inhoudstype',
+    'search_exact_matches' => 'Exacte matches',
     'search_tags' => 'Zoek tags',
-    'search_options' => 'Options',
+    '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_date_options' => 'Date Options',
+    'search_owned_by_me' => 'Eigendom van mij',
+    'search_date_options' => 'Datum opties',
     'search_updated_before' => 'Geupdate voor',
     'search_updated_after' => 'Geupdate na',
-    'search_created_before' => 'Gecreeerd voor',
-    'search_created_after' => 'Gecreeerd na',
-    'search_set_date' => 'Zet datum',
+    'search_created_before' => 'Gecreëerd voor',
+    'search_created_after' => 'Gecreëerd na',
+    'search_set_date' => 'Stel datum in',
     'search_update' => 'Update zoekresultaten',
 
     // Shelves
@@ -71,201 +78,203 @@ 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' => 'New Shelf',
+    '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' => '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' => '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' => 'New Book',
+    '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' => 'The most recently created books will appear here.',
-    'books_create' => 'Nieuw Boek Aanmaken',
-    'books_delete' => 'Boek Verwijderen',
-    'books_delete_named' => 'Verwijder Boek :bookName',
+    'books_new_empty' => 'De meest recent aangemaakte boeken verschijnen hier.',
+    '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' => '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' => 'Bekijk Andere Boeken',
-    'books_sort_save' => 'Nieuwe Order Opslaan',
+    '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 volgorde opslaan',
 
     // 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_delete_explain' => 'Dit verwijdert het hoofdstuk \':chapterName\', Alle pagina\'s zullen verwijdert worden.
-        en toegevoegd worden aan het bijbehorende boek.',
+    '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_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' => 'Draft Options',
+    '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' => 'Insert Drawing',
+    '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' => 'Copy Page',
-    'pages_copy_desination' => 'Copy Destination',
-    'pages_copy_success' => 'Page successfully copied',
-    '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' => 'Revision #:id',
-    'pages_revisions_numbered_changes' => 'Revision #:id Changes',
+    'pages_revisions_numbered' => 'Revisie #:id',
+    'pages_revisions_numbered_changes' => 'Revisie #:id wijzigingen',
     'pages_revisions_changelog' => 'Changelog',
     'pages_revisions_changes' => 'Wijzigingen',
-    'pages_revisions_current' => 'Huidige Versie',
-    'pages_revisions_preview' => 'Preview',
+    '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_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_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_edit_active' => [
-        'start_a' => ':count users have started editing this page',
-        'start_b' => ':userName has started editing this page',
+        'start_a' => ':count gebruikers zijn begonnen deze pagina te bewerken',
+        'start_b' => ':userName is begonnen met het bewerken van deze pagina',
         'time_a' => 'since the pages was last updated',
-        'time_b' => 'in the last :minCount minutes',
-        'message' => ':start :time. Take care not to overwrite each other\'s updates!',
+        'time_b' => 'in de laatste :minCount minuten',
+        'message' => ':start :time. Let op om elkaars updates niet te overschrijven!',
     ],
-    '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' => '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' => 'Chapter Tags',
-    'book_tags' => 'Book Tags',
-    'shelf_tags' => 'Shelf Tags',
+    'chapter_tags' => 'Labels van hoofdstuk',
+    'book_tags' => 'Labels van boeken',
+    'shelf_tags' => 'Labels van boekenplanken',
     'tag' => 'Label',
-    'tags' =>  'Tags',
-    'tag_name' =>  'Tag Name',
-    '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' => 'Remove this tag',
+    'tags_remove' => 'Dit label verwijderen',
     '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_delete_confirm' => 'Klik opnieuw op \'verwijderen\' om de bijlage definitief te verwijderen.',
+    '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_edit_file' => 'Bestand Bewerken',
+    'attachments_insert_link' => 'Bijlage link toevoegen aan pagina',
+    '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',
@@ -274,12 +283,12 @@ return [
     'attachments_file_uploaded' => 'Bestand succesvol geüpload',
     'attachments_file_updated' => 'Bestand succesvol bijgewerkt',
     'attachments_link_attached' => 'Link successfully gekoppeld aan de pagina',
-    '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' => 'Sjablonen',
+    'templates_set_as_template' => 'Pagina is een sjabloon',
+    'templates_explain_set_as_template' => 'Je kunt deze pagina als template instellen zodat de inhoud wordt gebruikt bij het maken van andere pagina\'s. Andere gebruikers kunnen deze template gebruiken als ze rechten hebben om deze pagina te bekijken.',
+    'templates_replace_content' => 'Pagina-inhoud vervangen',
+    'templates_append_content' => 'Toevoegen aan pagina-inhoud',
+    'templates_prepend_content' => 'Voeg vooraan toe aan pagina-inhoud',
 
     // Profile View
     'profile_user_for_x' => 'Lid sinds :time',
@@ -287,12 +296,12 @@ return [
     'profile_not_created_pages' => ':userName heeft geen pagina\'s gemaakt',
     'profile_not_created_chapters' => ':userName heeft geen hoofdstukken gemaakt',
     'profile_not_created_books' => ':userName heeft geen boeken gemaakt',
-    'profile_not_created_shelves' => ':userName has not created any shelves',
+    'profile_not_created_shelves' => ':userName heeft nog geen boekenplanken gemaakt',
 
     // Comments
     'comment' => 'Reactie',
     'comments' => 'Reacties',
-    'comment_add' => 'Add Comment',
+    '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',
@@ -304,12 +313,12 @@ 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' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
+    '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.'
-];
\ No newline at end of file
+];
index dca27ca396229562e3bc59cf578d1075fd630fb2..7518314733ccde1352163b63eaf092cc5eec6cc2 100644 (file)
@@ -13,7 +13,7 @@ return [
     'email_already_confirmed' => 'Het e-mailadres is al bevestigd. Probeer in te loggen.',
     'email_confirmation_invalid' => 'Deze bevestigingstoken is ongeldig, Probeer opnieuw te registreren.',
     'email_confirmation_expired' => 'De bevestigingstoken is verlopen, Een nieuwe bevestigingsmail is verzonden.',
-    'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed',
+    'email_confirmation_awaiting' => 'Het e-mail adres van dit account moet worden bevestigd',
     'ldap_fail_anonymous' => 'LDAP toegang kon geen \'anonymous bind\' uitvoeren',
     'ldap_fail_authed' => 'LDAP toegang was niet mogelijk met de opgegeven dn & wachtwoord',
     'ldap_extension_not_installed' => 'LDAP PHP-extensie is niet geïnstalleerd',
@@ -23,7 +23,7 @@ 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',
+    '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.',
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Het uploaden van het bestand is verlopen.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Bij het bijwerken van de bijlage bleek de pagina onjuist',
     'attachment_not_found' => 'Bijlage niet gevonden',
 
     // Pages
@@ -83,21 +82,24 @@ return [
     // Error pages
     '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' => 'If you expected this page to exist, you might not have permission to view it.',
+    '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',
     'back_soon' => 'Komt snel weer online.',
 
     // 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' => 'Geen autorisatie token gevonden',
+    'api_bad_authorization_format' => 'Een autorisatie token is gevonden, maar het formaat schijnt onjuist te zijn',
+    'api_user_token_not_found' => 'Er is geen overeenkomende API token gevonden voor de opgegeven autorisatie token',
+    'api_incorrect_token_secret' => 'Het opgegeven geheim voor de API token is onjuist',
+    'api_user_no_api_permission' => 'De eigenaar van de gebruikte API token heeft geen toestemming om API calls te maken',
+    'api_user_token_expired' => 'De gebruikte autorisatie token is verlopen',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'Fout opgetreden bij het verzenden van een test email:',
 
 ];
index e0f36816c12b4cbddaf722a0b6dd3dfb567251a8..4b27f03c20ac4372a8c379db1b15df72035c0ed1 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Wachtwoorden moeten overeenkomen en minimaal zes tekens lang zijn.',
     'user' => "We kunnen niemand vinden met dat e-mailadres.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Het wachtwoord reset token is ongeldig voor dit e-mailadres.',
     'sent' => 'We hebben je een link gestuurd om je wachtwoord te herstellen!',
     'reset' => 'Je wachtwoord is hersteld!',
 
index cdd1b6fef065da614dd5ab104491159d7c2d4c38..1cbc677ae0568991d9f17ae3b01adedf562605d9 100644 (file)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Applicatie Homepagina',
     'app_homepage_desc' => 'Selecteer een weergave om weer te geven op de homepage in plaats van de standaard weergave. Paginarechten worden genegeerd voor geselecteerde pagina\'s.',
     'app_homepage_select' => 'Selecteer een pagina',
+    'app_footer_links' => 'Voettekst links',
+    'app_footer_links_desc' => 'Voeg links toe om te laten zien in de voettekst van de site. Deze worden onderaan de meeste pagina\'s weergegeven, met inbegrip van pagina\'s die geen inloggen vereisen. U kunt een label van "trans::<key>" gebruiken om systeemgedefinieerde vertalingen te gebruiken. Bijvoorbeeld: Het gebruik van "trans:common.privacy_policy" biedt de vertaalde tekst "Privacybeleid" en "trans:common.terms_of_service" voor de vertaalde tekst "Servicevoorwaarden".',
+    'app_footer_links_label' => 'Link label',
+    'app_footer_links_url' => 'Link URL',
+    'app_footer_links_add' => 'Voettekst link toevoegen',
     'app_disable_comments' => 'Reacties uitschakelen',
     'app_disable_comments_toggle' => 'Opmerkingen uitschakelen',
     'app_disable_comments_desc' => 'Schakel opmerkingen uit op alle pagina\'s in de applicatie. Bestaande opmerkingen worden niet getoond.',
@@ -44,9 +49,9 @@ return [
     // Color settings
     'content_colors' => 'Kleuren inhoud',
     'content_colors_desc' => 'Stelt de kleuren in voor alle elementen in de pagina-organisatieleiding. Het kiezen van kleuren met dezelfde helderheid als de standaard kleuren wordt aanbevolen voor de leesbaarheid.',
-    'bookshelf_color' => 'Shelf Color',
-    'book_color' => 'Book Color',
-    'chapter_color' => 'Chapter Color',
+    'bookshelf_color' => 'Kleur van de Boekenplank',
+    'book_color' => 'Kleur van het Boek',
+    'chapter_color' => 'Kleur van het Hoofdstuk',
     'page_color' => 'Pagina kleur',
     'page_draft_color' => 'Klad pagina kleur',
 
@@ -56,7 +61,7 @@ return [
     'reg_enable_toggle' => 'Registratie inschakelen',
     'reg_enable_desc' => 'Wanneer registratie is ingeschakeld, kan de gebruiker zich aanmelden als een gebruiker. Na registratie krijgen ze een enkele, standaard gebruikersrol.',
     'reg_default_role' => 'Standaard rol na registratie',
-    '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' => 'De optie hierboven wordt niet gebruikt terwijl LDAP authenticatie actief is. Gebruikersaccounts voor niet-bestaande leden zullen automatisch worden gecreëerd als authenticatie tegen het gebruikte LDAP-systeem succesvol is.',
     'reg_email_confirmation' => 'E-mail bevestiging',
     'reg_email_confirmation_toggle' => 'E-mailbevestiging verplichten',
     'reg_confirm_email_desc' => 'Als domeinrestricties aan staan dan is altijd e-maibevestiging nodig. Onderstaande instelling wordt dan genegeerd.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Afbeeldingen in revisies negeren',
+    '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?',
     'maint_image_cleanup_success' => ':count potentieel ongebruikte afbeeldingen gevonden en verwijderd!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Test E-mail',
     'maint_send_test_email_mail_greeting' => 'E-mailbezorging lijkt te werken!',
     'maint_send_test_email_mail_text' => 'Gefeliciteerd! Nu je deze e-mailmelding hebt ontvangen, lijken je e-mailinstellingen correct te zijn geconfigureerd.',
+    'maint_recycle_bin_desc' => 'Verwijderde planken, boeken, hoofdstukken en pagina\'s worden naar de prullenbak gestuurd om ze te herstellen of definitief te verwijderen. Oudere items in de prullenbak kunnen automatisch worden verwijderd, afhankelijk van de systeemconfiguratie.',
+    'maint_recycle_bin_open' => 'Prullenbak openen',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Herstellen',
+    'recycle_bin_contents_empty' => 'De prullenbak is momenteel leeg',
+    'recycle_bin_empty' => 'Prullenbak legen',
+    'recycle_bin_empty_confirm' => 'Dit zal permanent alle items in de prullenbak vernietigen, inclusief inhoud die in elk item zit. Weet u zeker dat u de prullenbak wilt legen?',
+    'recycle_bin_destroy_confirm' => 'Deze actie zal dit item permanent verwijderen, samen met alle onderliggende elementen hieronder vanuit het systeem en u kunt deze inhoud niet herstellen. Weet u zeker dat u dit item permanent wilt verwijderen?',
+    'recycle_bin_destroy_list' => 'Te vernietigen objecten',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Audit Log',
+    'audit_desc' => 'Dit auditlogboek toont een lijst met activiteiten die in het systeem zijn gedaan. Deze lijst is niet gefilterd, in tegenstelling tot vergelijkbare activiteitenlijsten in het systeem waar rechtenfilters worden toegepast.',
+    'audit_event_filter' => 'Gebeurtenis filter',
+    'audit_event_filter_no_filter' => 'Geen filter',
+    'audit_deleted_item' => 'Verwijderd Item',
+    'audit_deleted_item_name' => 'Naam: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Rollen',
@@ -96,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',
@@ -103,9 +147,11 @@ return [
     'role_manage_entity_permissions' => 'Beheer alle boeken-, hoofdstukken- en paginaresitrcties',
     'role_manage_own_entity_permissions' => 'Beheer restricties van je eigen boeken, hoofdstukken en pagina\'s',
     'role_manage_page_templates' => 'Paginasjablonen beheren',
-    'role_access_api' => 'Access system API',
+    '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.',
     'role_asset_admins' => 'Beheerders krijgen automatisch toegang tot alle inhoud, maar deze opties kunnen interface opties tonen of verbergen.',
     'role_all' => 'Alles',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Gebruikersprofiel',
     'users_add_new' => 'Gebruiker toevoegen',
     'users_search' => 'Gebruiker zoeken',
+    'users_latest_activity' => 'Laatste activiteit',
     'users_details' => 'Gebruiker details',
     'users_details_desc' => 'Stel een weergavenaam en e-mailadres in voor deze gebruiker. Het e-mailadres zal worden gebruikt om in te loggen.',
     'users_details_desc_no_email' => 'Stel een weergavenaam in voor deze gebruiker zodat anderen deze kunnen herkennen.',
@@ -131,13 +178,16 @@ return [
     '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',
-    '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' => 'Dit is het ID dat gebruikt wordt om deze gebruiker te vergelijken met uw externe verificatiesysteem.',
     'users_password_warning' => 'Vul onderstaande formulier alleen in als je het wachtwoord wilt aanpassen:',
     'users_system_public' => 'De eigenschappen van deze gebruiker worden voor elke gastbezoeker gebruikt. Er kan niet mee ingelogd worden en wordt automatisch toegewezen.',
     'users_delete' => 'Verwijder gebruiker',
     'users_delete_named' => 'Verwijder gebruiker :userName',
     'users_delete_warning' => 'Dit zal de gebruiker \':userName\' volledig uit het systeem verwijderen.',
     'users_delete_confirm' => 'Weet je zeker dat je deze gebruiker wilt verwijderen?',
+    'users_migrate_ownership' => 'Draag eigendom over',
+    'users_migrate_ownership_desc' => 'Selecteer een gebruiker hier als u wilt dat een andere gebruiker de eigenaar wordt van alle items die momenteel eigendom zijn van deze gebruiker.',
+    'users_none_selected' => 'Geen gebruiker geselecteerd',
     'users_delete_success' => 'Gebruiker succesvol verwijderd',
     'users_edit' => 'Bewerk Gebruiker',
     'users_edit_profile' => 'Bewerk Profiel',
@@ -153,31 +203,35 @@ return [
     'users_social_connected' => ':socialAccount account is succesvol aan je profiel gekoppeld.',
     'users_social_disconnected' => ':socialAccount account is succesvol ontkoppeld van je profiel.',
     '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_none' => 'Er zijn geen API-tokens gemaakt voor deze gebruiker',
+    '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' => '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' => 'API-token aanmaken',
+    'user_api_token_name' => 'Naam',
+    'user_api_token_name_desc' => 'Geef je token een leesbare naam als een toekomstige herinnering aan het beoogde doel.',
+    'user_api_token_expiry' => 'Vervaldatum',
+    'user_api_token_expiry_desc' => 'Stel een datum in waarop deze token verloopt. Na deze datum zullen aanvragen die met deze token zijn ingediend niet langer werken. Als dit veld leeg blijft, wordt een vervaldatum van 100 jaar in de toekomst ingesteld.',
+    'user_api_token_create_secret_message' => 'Onmiddellijk na het aanmaken van dit token zal een "Token ID" en "Token Geheim" worden gegenereerd en weergegeven. Het geheim zal slechts één keer getoond worden. Kopieer de waarde dus eerst op een veilige plaats voordat u doorgaat.',
+    'user_api_token_create_success' => 'API token succesvol aangemaakt',
+    'user_api_token_update_success' => 'API token succesvol bijgewerkt',
     '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' => 'Dit is een niet bewerkbaar systeem gegenereerde id voor dit token dat moet worden verstrekt in API-verzoeken.',
+    'user_api_token_secret' => 'Geheime token sleutel',
+    'user_api_token_secret_desc' => 'Dit is een systeem gegenereerd geheim voor dit token dat moet worden verstrekt in API verzoeken. Dit wordt maar één keer weergegeven, dus kopieër deze waarde naar een veilige plaats.',
+    'user_api_token_created' => 'Token gemaakt :timeAgo',
+    'user_api_token_updated' => 'Token bijgewerkt :timeAgo',
+    'user_api_token_delete' => 'Token Verwijderen',
+    'user_api_token_delete_warning' => 'Dit zal de API-token met de naam \':tokenName\' volledig uit het systeem verwijderen.',
+    'user_api_token_delete_confirm' => 'Weet u zeker dat u deze API-token wilt verwijderen?',
+    'user_api_token_delete_success' => 'API-token succesvol verwijderd',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Catalaans',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index f85e5786f78b0cfc2385873a38928e5de7857e16..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute moet minstens :min karakters bevatten.',
         'array'   => ':attribute moet minstens :min items bevatten.',
     ],
-    'no_double_extension'  => ':attribute mag maar een enkele bestandsextensie hebben.',
     'not_in'               => ':attribute is ongeldig.',
     'not_regex'            => ':attribute formaat is ongeldig.',
     'numeric'              => ':attribute moet een getal zijn.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute veld is verplicht wanneer :values niet ingesteld is.',
     'required_without_all' => ':attribute veld is verplicht wanneer geen van :values ingesteld zijn.',
     'same'                 => ':attribute en :other moeten overeenkomen.',
+    'safe_url'             => 'De opgegeven link is mogelijk niet veilig.',
     'size'                 => [
         'numeric' => ':attribute moet :size zijn.',
         'file'    => ':attribute moet :size kilobytes 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 f641ca23212d7f450bbd831499c310c163f5533d..5ca5fd9f416d237a413427b99db04553a99a797a 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'usunięto półkę',
     'bookshelf_delete_notification'    => 'Półka usunięta pomyślnie',
 
+    // 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',
+
     // Other
     'commented_on'                => 'skomentował',
+    'permissions_update'          => 'zaktualizowane uprawnienia',
 ];
index bb46cb2de9a00a05ee3dfb224bef3e355511b09e..d2439f2d3bbe4191ee8a6019fe6ed2401405c54d 100644 (file)
@@ -43,7 +43,7 @@ return [
     '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.',
     'reset_password_send_button' => 'Wyślij link do resetowania hasła',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'Link z resetem hasła zostanie wysłany na :email jeśli mamy ten adres w systemie.',
     'reset_password_success' => 'Hasło zostało zresetowane pomyślnie.',
     'email_reset_subject' => 'Resetowanie hasła do :appName',
     'email_reset_text' => 'Otrzymujesz tę wiadomość ponieważ ktoś zażądał zresetowania hasła do Twojego konta.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Hasło zostało ustawione, teraz masz dostęp do :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.',
 ];
\ No newline at end of file
index 9fe31385832f428caadde57266384cdcc4abba51..42a0a312b90ee71f8476c5a11ae75d5062d1496c 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Skopiuj',
     'reply' => 'Odpowiedz',
     'delete' => 'Usuń',
+    'delete_confirm' => 'Potwierdź usunięcie',
     'search' => 'Szukaj',
     'search_clear' => 'Wyczyść wyszukiwanie',
     'reset' => 'Resetuj',
     'remove' => 'Usuń',
     'add' => 'Dodaj',
-    'fullscreen' => 'Fullscreen',
+    'configure' => 'Configure',
+    'fullscreen' => 'Pełny ekran',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Dalej',
+    'previous' => 'Wstecz',
 
     // Sort Options
     'sort_options' => 'Opcje sortowania',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Sortuj rosnąco',
     'sort_descending' => 'Sortuj malejąco',
     'sort_name' => 'Nazwa',
+    'sort_default' => 'Domyślne',
     'sort_created_at' => 'Data utworzenia',
     'sort_updated_at' => 'Data aktualizacji',
 
@@ -54,26 +61,35 @@ 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',
     'grid_view' => 'Widok kafelkowy',
     'list_view' => 'Widok listy',
     'default' => 'Domyślny',
-    'breadcrumb' => 'Breadcrumb',
+    'breadcrumb' => 'Ścieżka nawigacji',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Menu profilu',
     'view_profile' => 'Zobacz profil',
     'edit_profile' => 'Edytuj profil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Tryb ciemny',
+    'light_mode' => 'Tryb jasny',
 
     // Layout tabs
     'tab_info' => 'Informacje',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Treść',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // 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:',
     'email_rights' => 'Wszelkie prawa zastrzeżone',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Polityka prywatności',
+    'terms_of_service' => 'Warunki usługi',
 ];
index b189c81710d136cc08b811e3707e8b29828ea454..b03ca35505ad6fb6008857f2989f95de86454773 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Wczytaj więcej',
     'image_image_name' => 'Nazwa obrazka',
     'image_delete_used' => 'Ten obrazek jest używany na stronach wyświetlonych poniżej.',
-    'image_delete_confirm' => 'Kliknij ponownie Usuń by potwierdzić usunięcie obrazka.',
+    'image_delete_confirm_text' => 'Czy na pewno chcesz usunąć ten obraz?',
     'image_select_image' => 'Wybierz obrazek',
     'image_dropzone' => 'Upuść obrazki tutaj lub kliknij by wybrać obrazki do przesłania',
     'images_deleted' => 'Usunięte obrazki',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Edytuj kod',
     'code_language' => 'Język kodu',
     'code_content' => 'Zawartość kodu',
+    'code_session_history' => 'Historia sesji',
     'code_save' => 'Zapisz kod',
 ];
index 9a1b7f9d296f81681cf96a8c7c123152cb540b75..138062109103ebae8b506a01748e1ee84b3466e5 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Utworzono :timeLength przez :user',
     'meta_updated' => 'Zaktualizowano :timeLength',
     'meta_updated_name' => 'Zaktualizowano :timeLength przez :user',
+    'meta_owned_name' => 'Właściciel :user',
     'entity_select' => 'Wybór obiektu',
     'images' => 'Obrazki',
     'my_recent_drafts' => 'Moje ostatnie wersje robocze',
     'my_recently_viewed' => 'Moje ostatnio wyświetlane',
+    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
+    'my_favourites' => 'My Favourites',
     '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',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Plik HTML',
     'export_pdf' => 'Plik PDF',
     'export_text' => 'Plik tekstowy',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Uprawnienia',
     'permissions_intro' => 'Jeśli włączone są indywidualne uprawnienia, to te uprawnienia będą miały priorytet względem pozostałych ustawionych uprawnień ról.',
     'permissions_enable' => 'Włącz własne uprawnienia',
     'permissions_save' => 'Zapisz uprawnienia',
+    'permissions_owner' => 'Właściciel',
 
     // Search
     'search_results' => 'Wyniki wyszukiwania',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Brak stron spełniających zadane kryterium',
     'search_for_term' => 'Szukaj :term',
     'search_more' => 'Więcej wyników',
-    'search_filters' => 'Filtry wyszukiwania',
+    'search_advanced' => 'Wyszukiwanie zaawansowane',
+    'search_terms' => 'Szukane frazy',
     'search_content_type' => 'Rodzaj treści',
     'search_exact_matches' => 'Dokładne frazy',
     'search_tags' => 'Tagi wyszukiwania',
@@ -57,6 +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_date_options' => 'Opcje dat',
     'search_updated_before' => 'Zaktualizowane przed',
     'search_updated_after' => 'Zaktualizowane po',
@@ -92,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' => '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' => '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.',
@@ -145,8 +153,7 @@ return [
     'chapters_create' => 'Utwórz nowy rozdział',
     'chapters_delete' => 'Usuń rozdział',
     'chapters_delete_named' => 'Usuń rozdział :chapterName',
-    'chapters_delete_explain' => 'To spowoduje usunięcie rozdziału \':chapterName\', Wszystkie strony zostaną usunięte
-        i dodane bezpośrednio do książki.',
+    'chapters_delete_explain' => 'Spowoduje to usunięcie rozdziału o nazwie \':chapterName\'. Wszystkie strony, które istnieją w tym rozdziale, również zostaną usunięte.',
     'chapters_delete_confirm' => 'Czy na pewno chcesz usunąć ten rozdział?',
     'chapters_edit' => 'Edytuj rozdział',
     'chapters_edit_named' => 'Edytuj rozdział :chapterName',
@@ -208,6 +215,7 @@ return [
     'pages_revisions' => 'Wersje strony',
     'pages_revisions_named' => 'Wersje strony :pageName',
     'pages_revision_named' => 'Wersja strony :pageName',
+    'pages_revision_restored_from' => 'Przywrócono z #:id; :summary',
     'pages_revisions_created_by' => 'Utworzona przez',
     'pages_revisions_date' => 'Data wersji',
     'pages_revisions_number' => '#',
@@ -256,7 +264,7 @@ return [
     'attachments_upload' => 'Dodaj plik',
     'attachments_link' => 'Dodaj link',
     'attachments_set_link' => 'Ustaw link',
-    'attachments_delete_confirm' => 'Kliknij ponownie Usuń by potwierdzić usunięcie załącznika.',
+    'attachments_delete' => 'Jesteś pewien, że chcesz usunąć ten załącznik?',
     'attachments_dropzone' => 'Upuść pliki lub kliknij tutaj by przesłać pliki',
     'attachments_no_files' => 'Nie przesłano żadnych plików',
     'attachments_explain_link' => 'Możesz załączyć link jeśli nie chcesz przesyłać pliku. Może być to link do innej strony lub link do pliku w chmurze.',
@@ -265,6 +273,7 @@ return [
     'attachments_link_url' => 'Link do pliku',
     'attachments_link_url_hint' => 'Strona lub plik',
     'attach' => 'Załącz',
+    'attachments_insert_link' => 'Dodaj link do załącznika do strony',
     'attachments_edit_file' => 'Edytuj plik',
     'attachments_edit_file_name' => 'Nazwa pliku',
     'attachments_edit_drop_upload' => 'Upuść pliki lub kliknij tutaj by przesłać pliki i nadpisać istniejące',
@@ -312,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index 5273906a021a79703df56435a181f1ef0017189c..488b753c65d4c138c7b8cb1ca1e06a83d0467b84 100644 (file)
@@ -13,7 +13,7 @@ return [
     'email_already_confirmed' => 'E-mail został potwierdzony, spróbuj się zalogować.',
     'email_confirmation_invalid' => 'Ten token jest nieprawidłowy lub został już wykorzystany. Spróbuj zarejestrować się ponownie.',
     'email_confirmation_expired' => 'Ten token potwierdzający wygasł. Wysłaliśmy Ci kolejny.',
-    'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed',
+    'email_confirmation_awaiting' => 'Adres e-mail dla używanego konta musi zostać potwierdzony',
     'ldap_fail_anonymous' => 'Dostęp LDAP przy użyciu anonimowego powiązania nie powiódł się',
     'ldap_fail_authed' => 'Dostęp LDAP przy użyciu tego DN i hasła nie powiódł się',
     'ldap_extension_not_installed' => 'Rozszerzenie LDAP PHP nie zostało zainstalowane',
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Przesyłanie pliku przekroczyło limit czasu.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Niezgodność strony podczas aktualizacji załącznika',
     'attachment_not_found' => 'Nie znaleziono załącznika',
 
     // Pages
@@ -83,21 +82,24 @@ return [
     // Error pages
     '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' => 'If you expected this page to exist, you might not have permission to view it.',
+    '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',
     'back_soon' => 'Niedługo zostanie uruchomiona ponownie.',
 
     // 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' => 'Nie znaleziono tokenu autoryzacji dla żądania',
+    'api_bad_authorization_format' => 'Token autoryzacji został znaleziony w żądaniu, ale format okazał się nieprawidłowy',
+    'api_user_token_not_found' => 'Nie znaleziono pasującego tokenu API dla podanego tokenu autoryzacji',
+    'api_incorrect_token_secret' => 'Podany sekret dla tego API jest nieprawidłowy',
+    'api_user_no_api_permission' => 'Właściciel używanego tokenu API nie ma uprawnień do wykonywania zapytań do API',
+    'api_user_token_expired' => 'Token uwierzytelniania wygasł',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'Błąd podczas wysyłania testowej wiadomości e-mail:',
 
 ];
index 02f9201272abbbfb1a0e0a16e48373ac1f2aef07..7b67bf38de6a9dc724b7279c733d80357604761b 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Hasło musi zawierać co najmniej 6 znaków i być zgodne z powtórzeniem.',
     'user' => "Nie znaleziono użytkownika o takim adresie e-mail.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Token resetowania hasła jest nieprawidłowy dla tego adresu e-mail.',
     'sent' => 'Wysłaliśmy Ci link do resetowania hasła!',
     'reset' => 'Twoje hasło zostało zresetowane!',
 
index eefe2b19ed84c82f766007782a0c1e94b75d8731..18121a9f24262ad02ab49b49b79dd50c464996f5 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".',
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Strona główna',
     'app_homepage_desc' => 'Wybierz widok, który będzie wyświetlany na stronie głównej zamiast w widoku domyślnego. Uprawnienia dostępowe są ignorowane dla wybranych stron.',
     'app_homepage_select' => 'Wybierz stronę',
+    'app_footer_links' => 'Linki w stopce',
+    'app_footer_links_desc' => 'Dodaj linki do pokazania w stopce witryny. Będą one wyświetlane na dole większości stron, włącznie z tymi, które nie wymagają logowania. Możesz użyć etykiety "trans::<key>" aby użyć tłumaczeń zdefiniowanych przez system. Na przykład: Używanie "trans::common.privacy_policy" zapewni przetłumaczony tekst "Polityka prywatności" i "trans::common.terms_of_service" zapewni przetłumaczony tekst "Warunki korzystania z usługi".',
+    'app_footer_links_label' => 'Etykieta linku',
+    'app_footer_links_url' => 'URL odnośnika',
+    'app_footer_links_add' => 'Dodaj link w stopce',
     'app_disable_comments' => 'Wyłącz komentarze',
     'app_disable_comments_toggle' => 'Wyłącz komentowanie',
     'app_disable_comments_desc' => 'Wyłącz komentarze na wszystkich stronach w aplikacji. Istniejące komentarze nie będą pokazywane.',
@@ -54,9 +59,9 @@ 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' => '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' => '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',
     'reg_email_confirmation_toggle' => 'Wymagaj potwierdzenia adresu email',
     'reg_confirm_email_desc' => 'Jeśli restrykcje domenowe zostały ustawione, potwierdzenie adresu stanie się konieczne, a poniższa wartośc zostanie zignorowana.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignoruje obrazki w poprzednich wersjach',
+    '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ąć?',
     'maint_image_cleanup_success' => ':count potencjalnie nieużywane obrazki zostały znalezione i usunięte!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'E-mail testowy',
     'maint_send_test_email_mail_greeting' => 'Wygląda na to, że wysyłka wiadomości e-mail działa!',
     'maint_send_test_email_mail_text' => 'Gratulacje! Otrzymałeś tego e-maila więc Twoje ustawienia poczty elektronicznej wydają się być prawidłowo skonfigurowane.',
+    'maint_recycle_bin_desc' => 'Usunięte półki, książki, rozdziały i strony są wysyłane do kosza, aby mogły zostać przywrócone lub trwale usunięte. Starsze przedmioty w koszu mogą zostać automatycznie usunięte po pewnym czasie w zależności od konfiguracji systemu.',
+    'maint_recycle_bin_open' => 'Otwórz kosz',
+
+    // Recycle Bin
+    'recycle_bin' => 'Kosz',
+    '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' => 'Parent',
+    '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' => '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' => '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' => '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_restore_parent' => 'Restore Parent',
+    '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_event_filter' => 'Filtry Wydarzeń',
+    'audit_event_filter_no_filter' => 'Brak filtra',
+    'audit_deleted_item' => 'Usunięta pozycja',
+    'audit_deleted_item_name' => 'Nazwa: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Role',
@@ -96,6 +139,7 @@ return [
     'role_details' => 'Szczegóły roli',
     'role_name' => 'Nazwa roli',
     'role_desc' => 'Krótki opis roli',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'Zewnętrzne identyfikatory uwierzytelniania',
     'role_system' => 'Uprawnienia systemowe',
     'role_manage_users' => 'Zarządzanie użytkownikami',
@@ -103,9 +147,11 @@ return [
     'role_manage_entity_permissions' => 'Zarządzanie uprawnieniami książek, rozdziałów i stron',
     'role_manage_own_entity_permissions' => 'Zarządzanie uprawnieniami własnych książek, rozdziałów i stron',
     'role_manage_page_templates' => 'Zarządzaj szablonami stron',
-    'role_access_api' => 'Access system API',
+    'role_access_api' => 'Dostęp do systemowego API',
     'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Zarządzanie zasobami',
+    'roles_system_warning' => 'Pamiętaj, że dostęp do trzech powyższych uprawnień może pozwolić użytkownikowi na zmianę własnych uprawnień lub uprawnień innych osób w systemie. Przypisz tylko role z tymi uprawnieniami do zaufanych użytkowników.',
     'role_asset_desc' => 'Te ustawienia kontrolują zarządzanie zasobami systemu. Uprawnienia książek, rozdziałów i stron nadpisują te ustawienia.',
     'role_asset_admins' => 'Administratorzy mają automatycznie dostęp do wszystkich treści, ale te opcję mogą być pokazywać lub ukrywać opcje interfejsu użytkownika.',
     'role_all' => 'Wszyscy',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Profil użytkownika',
     'users_add_new' => 'Dodaj użytkownika',
     'users_search' => 'Wyszukaj użytkownika',
+    'users_latest_activity' => 'Ostatnia aktywność',
     'users_details' => 'Szczegóły użytkownika',
     'users_details_desc' => 'Ustaw wyświetlaną nazwę i adres e-mail dla tego użytkownika. Adres e-mail zostanie wykorzystany do zalogowania się do aplikacji.',
     'users_details_desc_no_email' => 'Ustaw wyświetlaną nazwę dla tego użytkownika, aby inni mogli go rozpoznać.',
@@ -131,17 +178,20 @@ return [
     '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',
-    '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' => 'Jest to identyfikator używany do dopasowania tego użytkownika podczas komunikacji z zewnętrznym systemem uwierzytelniania.',
     'users_password_warning' => 'Wypełnij poniżej tylko jeśli chcesz zmienić swoje hasło:',
     'users_system_public' => 'Ten użytkownik reprezentuje każdego gościa odwiedzającego tę aplikację. Nie można się na niego zalogować, lecz jest przyznawany automatycznie.',
     'users_delete' => 'Usuń użytkownika',
     'users_delete_named' => 'Usuń :userName',
     'users_delete_warning' => 'To usunie użytkownika \':userName\' z systemu.',
     'users_delete_confirm' => 'Czy na pewno chcesz usunąć tego użytkownika?',
-    'users_delete_success' => 'Użytkownik usunięty pomyślnie',
+    'users_migrate_ownership' => 'Migracja Własności',
+    'users_migrate_ownership_desc' => 'Wybierz użytkownika tutaj, jeśli chcesz, aby inny użytkownik stał się właścicielem wszystkich elementów będących obecnie w posiadaniu tego użytkownika.',
+    'users_none_selected' => 'Nie wybrano użytkownika',
+    'users_delete_success' => 'Użytkownik pomyślnie usunięty',
     'users_edit' => 'Edytuj użytkownika',
     'users_edit_profile' => 'Edytuj profil',
-    'users_edit_success' => 'Użytkownik zaktualizowany pomyśłnie',
+    'users_edit_success' => 'Użytkownik zaktualizowany pomyślnie',
     'users_avatar' => 'Avatar użytkownika',
     'users_avatar_desc' => 'Ten obrazek powinien posiadać wymiary 256x256px.',
     'users_preferred_language' => 'Preferowany język',
@@ -152,32 +202,36 @@ return [
     'users_social_disconnect' => 'Odłącz konto',
     'users_social_connected' => ':socialAccount zostało dodane do Twojego profilu.',
     'users_social_disconnected' => ':socialAccount zostało odłączone od Twojego 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' => 'Tokeny API',
+    'users_api_tokens_none' => 'Nie utworzono tokenów API dla tego użytkownika',
+    'users_api_tokens_create' => 'Utwórz token',
+    'users_api_tokens_expires' => 'Wygasa',
+    'users_api_tokens_docs' => 'Dokumentacja 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' => '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_create' => 'Utwórz klucz API',
+    'user_api_token_name' => 'Nazwa',
+    'user_api_token_name_desc' => 'Nadaj swojemu tokenowi czytelną nazwę jako opisującego jego cel.',
+    'user_api_token_expiry' => 'Data ważności',
+    'user_api_token_expiry_desc' => 'Ustaw datę, kiedy ten token wygasa. Po tej dacie żądania wykonane przy użyciu tego tokenu nie będą już działać. Pozostawienie tego pola pustego, ustawi ważność na 100 lat.',
+    'user_api_token_create_secret_message' => 'Natychmiast po utworzeniu tego tokenu zostanie wygenerowany i wyświetlony "Identyfikator tokenu"" i "Token Secret". Sekret zostanie wyświetlony tylko raz, więc przed kontynuacją upewnij się, że zostanie on skopiowany w bezpiecznie miejsce.',
+    'user_api_token_create_success' => 'Klucz API został poprawnie wygenerowany',
+    'user_api_token_update_success' => 'Klucz API został poprawnie zaktualizowany',
+    '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_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' => 'Jest to nieedytowalny identyfikator wygenerowany przez system dla tego tokenu, który musi być dostarczony w żądaniach API.',
+    'user_api_token_secret' => 'Token Api',
+    'user_api_token_secret_desc' => 'To jest wygenerowany przez system sekretny token, który musi być dostarczony w żądaniach API. Token zostanie wyświetlany tylko raz, więc skopiuj go w bezpiecznie miejsce.',
+    'user_api_token_created' => 'Token utworzony :timeAgo',
+    'user_api_token_updated' => 'Token zaktualizowany :timeAgo',
+    'user_api_token_delete' => 'Usuń token',
+    'user_api_token_delete_warning' => 'Spowoduje to całkowite usunięcie tokenu API o nazwie \':tokenName\' z systemu.',
+    'user_api_token_delete_confirm' => 'Czy jesteś pewien, że chcesz usunąć ten token?',
+    'user_api_token_delete_success' => 'Token API został poprawnie usunięty',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Kataloński',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 249d20ca7384b26407c2c1e7d815061fff05a471..d852d46b98ccafac5389bafefb36dcffd24673fa 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'         => 'The provided code is not valid or has already been used.',
     '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.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'Długość :attribute nie może być mniejsza niż :min znaków.',
         'array'   => 'Rozmiar :attribute musi posiadać co najmniej :min elementy.',
     ],
-    'no_double_extension'  => ':attribute może mieć tylko jedno rozszerzenie.',
     'not_in'               => 'Wartość :attribute jest nieprawidłowa.',
     'not_regex'            => 'Format :attribute jest nieprawidłowy.',
     'numeric'              => ':attribute musi być liczbą.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'Pole :attribute jest wymagane jeśli :values nie zostało wprowadzone.',
     'required_without_all' => 'Pole :attribute jest wymagane jeśli żadna z wartości :values nie została podana.',
     'same'                 => 'Pole :attribute i :other muszą być takie same.',
+    'safe_url'             => 'Podany link może nie być bezpieczny.',
     'size'                 => [
         'numeric' => ':attribute musi mieć długość :size.',
         'file'    => ':attribute musi mieć :size kilobajtów.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute musi być ciągiem znaków.',
     'timezone'             => ':attribute musi być prawidłową strefą czasową.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     '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 4cac54b2a706efa35cb8873ebc247c20420e65b5..8bf2ff9fcf1ad8fb5b1d58e7ac05e93b2f4ec22c 100644 (file)
@@ -6,43 +6,52 @@
 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'                 => 'página criada',
+    'page_create_notification'    => 'Página criada com sucesso',
+    'page_update'                 => 'página atualizada',
+    'page_update_notification'    => 'Página atualizada com sucesso',
+    'page_delete'                 => 'página eliminada',
+    'page_delete_notification'    => 'Página eliminada com sucesso',
+    'page_restore'                => 'página restaurada',
+    'page_restore_notification'   => 'Página restaurada com sucesso',
+    'page_move'                   => 'página movida',
 
     // 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'              => 'capítulo criado',
+    'chapter_create_notification' => 'Capítulo criado com sucesso',
+    'chapter_update'              => 'capítulo atualizado',
+    'chapter_update_notification' => 'Capítulo atualizado com sucesso',
+    'chapter_delete'              => 'capítulo excluído',
+    'chapter_delete_notification' => 'Capítulo excluído com sucesso',
+    'chapter_move'                => 'capítulo movido',
 
     // 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'                 => 'livro criado',
+    'book_create_notification'    => 'Livro criado com sucesso',
+    'book_update'                 => 'livro atualizado',
+    'book_update_notification'    => 'Livro atualizado com sucesso',
+    'book_delete'                 => 'livro eliminado',
+    'book_delete_notification'    => 'Livro eliminado com sucesso',
+    'book_sort'                   => 'livro ordenado',
+    'book_sort_notification'      => 'Livro reordenado com sucesso',
 
     // 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'            => 'estante criada',
+    'bookshelf_create_notification'    => 'Estante criada com sucesso',
+    'bookshelf_update'                 => 'estante atualizada',
+    'bookshelf_update_notification'    => 'Estante atualizada com sucesso',
+    'bookshelf_delete'                 => 'excluiu a prateleira',
+    'bookshelf_delete_notification'    => 'Estante eliminada com sucesso',
+
+    // Favourites
+    'favourite_add_notification' => '":name" foi adicionado aos seus favoritos',
+    'favourite_remove_notification' => '":name" foi removido dos seus favoritos',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+    'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
 
     // Other
-    'commented_on'                => 'commented on',
+    'commented_on'                => 'comentado a',
+    'permissions_update'          => 'permissões atualizadas',
 ];
index d64fce93a62d90889b2297a9e4f6482ad9046475..de3d35e97e1849e1318283c80ff37272ec66fba1 100644 (file)
  */
 return [
 
-    'failed' => 'These credentials do not match our records.',
-    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+    '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.',
 
     // 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' => 'Criar conta',
+    'log_in' => 'Iniciar sessão',
+    'log_in_with' => 'Iniciar sessão com :socialDriver',
+    'sign_up_with' => 'Criar conta com :socialDriver',
+    'logout' => 'Terminar sessão',
 
-    '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.',
+    'name' => 'Nome',
+    'username' => 'Nome de utilizador',
+    'email' => 'E-mail',
+    'password' => 'Palavra-passe',
+    'password_confirm' => 'Confirmar Palavra-passe',
+    'password_hint' => 'Deve ser maior que 7 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.',
+    'create_account' => 'Criar Conta',
+    'already_have_account' => 'Já possui uma conta?',
+    'dont_have_account' => 'Não possui uma conta?',
+    'social_login' => 'Inicio de Sessão com Redes Sociais',
+    'social_registration' => 'Registo com Redes Sociais',
+    'social_registration_text' => 'Registe e inicie sessão com recurso a outro serviço.',
 
-    '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.',
+    'register_thanks' => 'Obrigado por se registar!',
+    'register_confirm' => 'Por favor, verifique o seu e-mail e carregue no botão de confirmação para aceder :appName.',
+    'registrations_disabled' => 'Os registos estão temporariamente desativados',
+    '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' => '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' => '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.',
+    'reset_password_send_button' => 'Enviar o Link de Redefinição',
+    'reset_password_sent' => 'Um link de redefinição de palavra-passe será enviado para :email, se o endereço de e-mail for encontrado no sistema.',
+    'reset_password_success' => 'A sua palavra-passe foi redefinida com sucesso.',
+    'email_reset_subject' => 'Redefina a sua palavra-passe de :appName',
+    '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' => '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' => '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_resent' => 'E-mail de confirmação reenviado. Por favor, verifique a sua caixa de entrada.',
 
-    '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' => 'Endereço de E-mail Não Confirmado',
+    'email_not_confirmed_text' => 'O seu endereço de e-mail ainda não foi confirmado.',
+    'email_not_confirmed_click_link' => 'Por favor, carregue no link que se encontra no e-mail que lhe foi enviado após o seu registo.',
+    'email_not_confirmed_resend' => 'Caso não encontre o e-mail poderá reenviar a confirmação utilizando o formulário abaixo.',
+    'email_not_confirmed_resend_button' => 'Reenviar o E-mail de Confirmação',
 
     // 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!'
+    'user_invite_email_subject' => 'Você recebeu um convite para se juntar a :appName!',
+    'user_invite_email_greeting' => 'Uma conta foi criada para si em :appName.',
+    'user_invite_email_text' => 'Carregue no botão abaixo para definir uma palavra-passe de conta e obter acesso:',
+    'user_invite_email_action' => 'Defina a Palavra-passe da Conta',
+    '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!',
+
+    // 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.',
 ];
\ No newline at end of file
index 68c58b92ba4e9243fd1afae7eca30ed89feab4df..19a5dc24a9fb2294b77ba5a4d1c187c508016325 100644 (file)
@@ -5,75 +5,91 @@
 return [
 
     // Buttons
-    'cancel' => 'Cancel',
-    'confirm' => 'Confirm',
-    'back' => 'Back',
-    'save' => 'Save',
-    'continue' => 'Continue',
-    'select' => 'Select',
-    'toggle_all' => 'Toggle All',
-    'more' => 'More',
+    'cancel' => 'Cancelar',
+    'confirm' => 'Confirmar',
+    'back' => 'Voltar',
+    'save' => 'Guardar',
+    'continue' => 'Continuar',
+    'select' => 'Selecionar',
+    'toggle_all' => 'Alternar Todos',
+    'more' => 'Mais',
 
     // Form Labels
-    'name' => 'Name',
-    'description' => 'Description',
-    'role' => 'Role',
-    'cover_image' => 'Cover image',
-    'cover_image_description' => 'This image should be approx 440x250px.',
+    'name' => 'Nome',
+    'description' => 'Descrição',
+    'role' => 'Cargo',
+    'cover_image' => 'Imagem de capa',
+    'cover_image_description' => 'Esta imagem deve ser aproximadamente 440x250px.',
     
     // 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',
-    'search' => 'Search',
-    'search_clear' => 'Clear Search',
-    'reset' => 'Reset',
-    'remove' => 'Remove',
-    'add' => 'Add',
-    'fullscreen' => 'Fullscreen',
+    'actions' => 'Ações',
+    'view' => 'Visualizar',
+    'view_all' => 'Visualizar Todos',
+    'create' => 'Criar',
+    'update' => 'Atualizar',
+    'edit' => 'Editar',
+    'sort' => 'Ordenar',
+    'move' => 'Mover',
+    'copy' => 'Copiar',
+    'reply' => 'Responder',
+    'delete' => 'Eliminar',
+    'delete_confirm' => 'Confirmar eliminação',
+    'search' => 'Pesquisar',
+    'search_clear' => 'Limpar Pesquisa',
+    'reset' => 'Redefinir',
+    'remove' => 'Remover',
+    'add' => 'Adicionar',
+    'configure' => 'Configure',
+    'fullscreen' => 'Ecrã completo',
+    'favourite' => 'Favorito',
+    'unfavourite' => 'Retirar Favorito',
+    'next' => 'Próximo',
+    'previous' => 'Anterior',
 
     // Sort Options
-    'sort_options' => 'Sort Options',
-    'sort_direction_toggle' => 'Sort Direction Toggle',
-    'sort_ascending' => 'Sort Ascending',
-    'sort_descending' => 'Sort Descending',
-    'sort_name' => 'Name',
-    'sort_created_at' => 'Created Date',
-    'sort_updated_at' => 'Updated Date',
+    'sort_options' => 'Opções de Ordenação',
+    'sort_direction_toggle' => 'Alternar Direção de Ordenação',
+    'sort_ascending' => 'Ordenação Crescente',
+    'sort_descending' => 'Ordenação Decrescente',
+    'sort_name' => 'Nome',
+    'sort_default' => 'Padrão',
+    'sort_created_at' => 'Data de Criação',
+    'sort_updated_at' => 'Data de Atualização',
 
     // 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' => 'Utilizador Eliminado',
+    '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',
+    'grid_view' => 'Visualização em Grade',
+    'list_view' => 'Visualização em Lista',
+    'default' => 'Padrão',
+    'breadcrumb' => 'Caminho',
 
     // Header
-    'profile_menu' => 'Profile Menu',
-    'view_profile' => 'View Profile',
-    'edit_profile' => 'Edit Profile',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'header_menu_expand' => 'Expandir Menu de Cabeçalho',
+    'profile_menu' => 'Menu de Perfil',
+    'view_profile' => 'Visualizar Perfil',
+    'edit_profile' => 'Editar Perfil',
+    'dark_mode' => 'Modo Escuro',
+    'light_mode' => 'Modo Claro',
 
     // Layout tabs
-    'tab_info' => 'Info',
-    'tab_content' => 'Content',
+    'tab_info' => 'Informações',
+    'tab_info_label' => 'Separador: Mostrar Informação Secundária',
+    'tab_content' => 'Conteúdo',
+    'tab_content_label' => 'Separador: Mostrar Conteúdo Primário',
 
     // 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' => 'Se estiver com problemas ao carregar no botão ":actionText", copie e cole o URL abaixo no seu navegador:',
+    'email_rights' => 'Todos os direitos reservados',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Política de Privacidade',
+    'terms_of_service' => 'Termos de Utilização',
 ];
index d8e8981fb5fcf6ba8d15993453d4f8f2d07df970..bdec2022696dfe1327b0fda480da12c425068ed1 100644 (file)
@@ -5,29 +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' => 'Click delete again to confirm 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' => 'Selecionar Imagem',
+    'image_all' => 'Todas',
+    'image_all_title' => 'Visualizar todas as imagens',
+    'image_book_title' => 'Visualizar imagens relacionadas a este livro',
+    'image_page_title' => 'Visualizar imagens relacionadas a esta página',
+    'image_search_hint' => 'Pesquisar imagem por nome',
+    'image_uploaded' => 'Adicionada em :uploadedDate',
+    'image_load_more' => 'Carregar Mais',
+    'image_image_name' => 'Nome da Imagem',
+    'image_delete_used' => 'Esta imagem é utilizada nas páginas abaixo.',
+    'image_delete_confirm_text' => 'Tem certeza de que deseja eliminar esta imagem?',
+    'image_select_image' => 'Selecionar Imagem',
+    'image_dropzone' => 'Arraste imagens ou carregue aqui para fazer upload',
+    'images_deleted' => 'Imagens Eliminadas',
+    'image_preview' => 'Pré-visualização de Imagem',
+    'image_upload_success' => 'Carregamento da imagem efetuado com sucesso',
+    'image_update_success' => 'Detalhes da imagem atualizados com sucesso',
+    'image_delete_success' => 'Imagem eliminada com sucesso',
+    'image_upload_remove' => 'Remover',
 
     // Code Editor
-    'code_editor' => 'Edit Code',
-    'code_language' => 'Code Language',
-    'code_content' => 'Code Content',
-    'code_save' => 'Save Code',
+    'code_editor' => 'Editar Código',
+    'code_language' => 'Linguagem do Código',
+    'code_content' => 'Código',
+    'code_session_history' => 'Histórico de Sessão',
+    'code_save' => 'Guardar Código',
 ];
index 6bbc723b0abfc1e6b1f69e270bbb9d2afdbe851b..1cd5c277fba2de338e704ed4f2083ae479126309 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',
-    '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' => 'Criado recentemente',
+    'recently_created_pages' => 'Páginas Criadas Recentemente',
+    'recently_updated_pages' => 'Páginas Atualizadas Recentemente',
+    'recently_created_chapters' => 'Capítulos Criados Recentemente',
+    'recently_created_books' => 'Livros Criados Recentemente',
+    'recently_created_shelves' => 'Estantes Criadas Recentemente',
+    'recently_update' => 'Atualizados Recentemente',
+    'recently_viewed' => 'Visualizados Recentemente',
+    'recent_activity' => 'Atividade Recente',
+    'create_now' => 'Criar um agora',
+    'revisions' => 'Revisões',
+    'meta_revision' => 'Revisão #:revisionCount',
+    'meta_created' => 'Criado :timeLength',
+    'meta_created_name' => 'Criado :timeLength por :user',
+    'meta_updated' => 'Atualizado :timeLength',
+    'meta_updated_name' => 'Atualizado :timeLength por :user',
+    'meta_owned_name' => 'Propriedade de :user',
+    'entity_select' => 'Seleção de Entidade',
+    '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',
+    'export' => 'Exportar',
+    'export_html' => 'Arquivo Web contido',
+    'export_pdf' => 'Arquivo PDF',
+    'export_text' => 'Arquivo Texto',
+    'export_md' => 'Ficheiro Markdown',
 
     // 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' => 'Permissões',
+    'permissions_intro' => 'Uma vez ativadas, estas permissões terão prioridade sobre quaisquer outro conjunto de permissões.',
+    'permissions_enable' => 'Ativar Permissões Personalizadas',
+    'permissions_save' => 'Guardar Permissões',
+    'permissions_owner' => 'Proprietário',
 
     // Search
-    '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_filters' => 'Search Filters',
-    '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_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_results' => 'Resultado(s) da Pesquisa',
+    'search_total_results_found' => ':count resultado encontrado|:count resultados encontrados',
+    'search_clear' => 'Limpar Pesquisa',
+    'search_no_pages' => 'Nenhuma página corresponde à pesquisa',
+    'search_for_term' => 'Pesquisar por :term',
+    'search_more' => 'Mais Resultados',
+    'search_advanced' => 'Pesquisa Avançada',
+    'search_terms' => 'Termos da Pesquisa',
+    'search_content_type' => 'Tipo de Conteúdo',
+    'search_exact_matches' => 'Correspondências Exatas',
+    'search_tags' => 'Persquisar Tags',
+    'search_options' => 'Opções',
+    'search_viewed_by_me' => 'Visualizado por mim',
+    'search_not_viewed_by_me' => 'Não visualizado por mim',
+    'search_permissions_set' => 'Permissão definida',
+    'search_created_by_me' => 'Criado por mim',
+    'search_updated_by_me' => 'Atualizado por mim',
+    'search_owned_by_me' => 'Propriedade minha',
+    'search_date_options' => 'Opções de Data',
+    'search_updated_before' => 'Atualizado antes de',
+    'search_updated_after' => 'Atualizado depois de',
+    'search_created_before' => 'Criado antes de',
+    'search_created_after' => 'Criado depois de',
+    'search_set_date' => 'Definir Data',
+    'search_update' => 'Atualizar pesquisa',
 
     // 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' => 'Estante',
+    'shelves' => 'Estantes',
+    'x_shelves' => ':count Estante|:count Estantes',
+    'shelves_long' => 'Estantes de Livros',
+    'shelves_empty' => 'Nenhuma estante foi criada',
+    'shelves_create' => 'Criar Nova Estante',
+    'shelves_popular' => 'Estantes Populares',
+    'shelves_new' => 'Estantes Novas',
+    'shelves_new_action' => 'Nova Estante',
+    'shelves_popular_empty' => 'As estantes mais populares serão mostradas aqui.',
+    'shelves_new_empty' => 'As mais recentes estantes criadas serão mostradas aqui.',
+    'shelves_save' => 'Guardar Estante',
+    'shelves_books' => 'Livros nesta estante',
+    'shelves_add_books' => 'Adicionar livros a esta estante',
+    'shelves_drag_books' => 'Arraste livros aqui para adicioná-los a esta estante',
+    'shelves_empty_contents' => 'Esta estante não tem livros atribuídos',
+    'shelves_edit_and_assign' => 'Editar estante para atribuir livros',
+    'shelves_edit_named' => 'Editar Estante de Livros :name',
+    'shelves_edit' => 'Editar Estante de Livros',
+    'shelves_delete' => 'Eliminar Estante de Livros',
+    'shelves_delete_named' => 'Excluir Prateleira de Livros :name',
+    'shelves_delete_explain' => "A ação vai eliminar a estante de nome ':name'. Os livros nela presentes não serão eliminados.",
+    'shelves_delete_confirmation' => 'Tem a certeza que quer eliminar esta estante de livros?',
+    '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' => '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 estante a todos os livros nela contidos. Antes de ativar, assegure-se de que quaisquer alterações nas permissões desta estante foram guardadas.',
+    'shelves_copy_permission_success' => 'Permissões de estante copiadas para :count livros',
 
     // 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' => 'Livro',
+    'books' => 'Livros',
+    'x_books' => ':count Livro|:count Livros',
+    'books_empty' => 'Nenhum livro foi criado',
+    'books_popular' => 'Livros Populares',
+    'books_recent' => 'Livros Recentes',
+    'books_new' => 'Livros Novos',
+    'books_new_action' => 'Novo Livro',
+    'books_popular_empty' => 'Os livros mais populares serão mostrados aqui.',
+    'books_new_empty' => 'Os livros mais recentemente criados serão mostrados aqui.',
+    'books_create' => 'Criar Livro Novo',
+    'books_delete' => 'Eliminar Livro',
+    'books_delete_named' => 'Eliminar Livro :bookName',
+    'books_delete_explain' => 'A ação vai eliminar o livro com de nome \':bookName\'. Todas as páginas e capítulos serão também removidos.',
+    'books_delete_confirmation' => 'Tem a certeza que quer eliminar este livro?',
+    'books_edit' => 'Editar Livro',
+    'books_edit_named' => 'Editar Livro :bookName',
+    'books_form_book_name' => 'Nome do Livro',
+    'books_save' => 'Guardar Livro',
+    'books_permissions' => 'Permissões do Livro',
+    'books_permissions_updated' => 'Permissões do Livro Atualizadas',
+    'books_empty_contents' => 'Nenhuma página ou capítulo foram criados para este livro.',
+    'books_empty_create_page' => 'Criar uma nova página',
+    'books_empty_sort_current_book' => 'Ordenar o livro atual',
+    'books_empty_add_chapter' => 'Adicionar um capítulo',
+    'books_permissions_active' => 'Permissões do Livro Ativas',
+    'books_search_this' => 'Pesquisar neste livro',
+    'books_navigation' => 'Navegação do Livro',
+    'books_sort' => 'Ordenar Conteúdos do Livro',
+    'books_sort_named' => 'Ordenar Livro :bookName',
+    'books_sort_name' => 'Ordenar por Nome',
+    'books_sort_created' => 'Ordenar por Data de Criação',
+    'books_sort_updated' => 'Ordenar por Data de Atualização',
+    'books_sort_chapters_first' => 'Capítulos Primeiro',
+    'books_sort_chapters_last' => 'Capítulos por Último',
+    'books_sort_show_other' => 'Mostrar Outros Livros',
+    'books_sort_save' => 'Guardar Nova Ordenação',
 
     // 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 will be removed and added directly to the parent book.',
-    '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' => 'Capítulo',
+    'chapters' => 'Capítulos',
+    'x_chapters' => ':count Capítulo|:count Capítulos',
+    'chapters_popular' => 'Capítulos Populares',
+    'chapters_new' => 'Novo Capítulo',
+    'chapters_create' => 'Criar Novo Capítulo',
+    'chapters_delete' => 'Eliminar Capítulo',
+    'chapters_delete_named' => 'Eliminar Capítulo :chapterName',
+    'chapters_delete_explain' => 'Isto irá eliminar o capítulo com o nome \':chapterName\'. Todas as páginas existentes dentro do mesmo serão também eliminadas.',
+    'chapters_delete_confirm' => 'Tem certeza que deseja eliminar o capítulo?',
+    'chapters_edit' => 'Editar Capítulo',
+    'chapters_edit_named' => 'Editar Capítulo :chapterName',
+    'chapters_save' => 'Guardar Capítulo',
+    'chapters_move' => 'Mover Capítulo',
+    'chapters_move_named' => 'Mover Capítulo :chapterName',
+    'chapter_move_success' => 'Capítulo movido para :bookName',
+    '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',
+    'chapters_permissions_success' => 'Permissões de Capítulo Atualizadas',
+    'chapters_search_this' => 'Pesquisar neste Capítulo',
 
     // 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',
+    'page' => 'Página',
+    'pages' => 'Páginas',
+    'x_pages' => ':count Página|:count Páginas',
+    'pages_popular' => 'Páginas Populares',
+    'pages_new' => 'Nova Página',
+    'pages_attachments' => 'Anexos',
+    'pages_navigation' => 'Navegação da Página',
+    'pages_delete' => 'Eliminar Página',
+    'pages_delete_named' => 'Eliminar Página :pageName',
+    'pages_delete_draft_named' => 'Eliminar Rascunho de Página de nome :pageName',
+    'pages_delete_draft' => 'Eliminar Rascunho de Página',
+    'pages_delete_success' => 'Página eliminada',
+    'pages_delete_draft_success' => 'Rascunho de página eliminado',
+    'pages_delete_confirm' => 'Tem certeza que deseja eliminar a página?',
+    'pages_delete_draft_confirm' => 'Tem certeza que deseja eliminar o rascunho de página?',
+    'pages_editing_named' => 'A Editar a Página :pageName',
+    'pages_edit_draft_options' => 'Opções de Rascunho',
+    'pages_edit_save_draft' => 'Guardar Rascunho',
+    'pages_edit_draft' => 'Editar Rascunho de Página',
+    'pages_editing_draft' => 'A Editar Rascunho',
+    'pages_editing_page' => 'A Editar Página',
+    'pages_edit_draft_save_at' => 'Rascunho guardado em ',
+    'pages_edit_delete_draft' => 'Eliminar Rascunho',
+    'pages_edit_discard_draft' => 'Descartar Rascunho',
+    'pages_edit_set_changelog' => 'Relatar Alterações',
+    'pages_edit_enter_changelog_desc' => 'Digite uma breve descrição das alterações efetuadas por si',
+    'pages_edit_enter_changelog' => 'Inserir Alterações',
+    'pages_save' => 'Guardar Página',
+    'pages_title' => 'Título da Página',
+    'pages_name' => 'Nome da Página',
     '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_revisions_created_by' => 'Created By',
-    'pages_revisions_date' => 'Revision Date',
+    'pages_md_preview' => 'Pré-Visualização',
+    'pages_md_insert_image' => 'Inserir Imagem',
+    'pages_md_insert_link' => 'Inserir Link para Entidade',
+    'pages_md_insert_drawing' => 'Inserir Desenho',
+    'pages_not_in_chapter' => 'A página não está dentro de um capítulo',
+    'pages_move' => 'Mover Página',
+    'pages_move_success' => 'Pagina movida para ":parentName"',
+    'pages_copy' => 'Copiar Página',
+    'pages_copy_desination' => 'Destino da Cópia',
+    'pages_copy_success' => 'Página copiada com sucesso',
+    'pages_permissions' => 'Permissões da Página',
+    'pages_permissions_success' => 'Permissões da Página atualizadas',
+    'pages_revision' => 'Revisão',
+    'pages_revisions' => 'Revisões da Página',
+    'pages_revisions_named' => 'Revisões de Página para :pageName',
+    'pages_revision_named' => 'Revisão de Página para :pageName',
+    'pages_revision_restored_from' => 'Recuperado de #:id; :summary',
+    'pages_revisions_created_by' => 'Criado por',
+    'pages_revisions_date' => 'Data da Revisão',
     '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' => 'Revisão #:id',
+    'pages_revisions_numbered_changes' => 'Alterações da Revisão #:id',
+    'pages_revisions_changelog' => 'Relatório de Alterações',
+    'pages_revisions_changes' => 'Alterações',
+    'pages_revisions_current' => 'Versão Atual',
+    'pages_revisions_preview' => 'Pré-Visualização',
+    'pages_revisions_restore' => 'Restaurar',
+    'pages_revisions_none' => 'Essa página não tem revisões',
+    'pages_copy_link' => 'Copiar Link',
+    'pages_edit_content_link' => 'Editar Conteúdo',
+    'pages_permissions_active' => 'Permissões de Página Ativas',
+    'pages_initial_revision' => 'Publicação Inicial',
+    '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_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 usuários iniciaram a edição dessa página',
+        'start_b' => ':userName iniciou a edição desta página',
+        'time_a' => 'desde que a página foi atualizada pela última vez',
+        'time_b' => 'nos últimos :minCount minutos',
+        'message' => ':start :time. Tenha cuidado para não sobrescrever atualizações de outras pessoas!',
     ],
-    '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' => 'Rascunho descartado. O editor foi atualizado com o conteúdo atual da página',
+    'pages_specific' => 'Página Específica',
+    'pages_is_template' => 'Modelo de Página',
 
     // 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_confirm' => 'Click delete again to confirm 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_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' => 'Etiquetas de Página',
+    'chapter_tags' => 'Etiquetas do Capítulo',
+    'book_tags' => 'Etiquetas do Livro',
+    'shelf_tags' => 'Etiquetas da Prateleira',
+    'tag' => 'Etiqueta',
+    'tags' =>  'Etiquetas',
+    'tag_name' =>  'Nome da Etiqueta',
+    'tag_value' => 'Valor da Etiqueta (Opcional)',
+    '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',
+    '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.',
+    'attachments_items' => 'Itens Anexados',
+    'attachments_upload' => 'Carregamento de Arquivos',
+    'attachments_link' => 'Anexar Link',
+    'attachments_set_link' => 'Definir Link',
+    'attachments_delete' => 'Tem certeza de que deseja eliminar este anexo?',
+    'attachments_dropzone' => 'Arraste arquivos para aqui ou clique para os anexar',
+    'attachments_no_files' => 'Nenhum arquivo foi enviado',
+    'attachments_explain_link' => 'Pode anexar um link se preferir não fazer o carregamento do arquivo. O link poderá ser para uma outra página ou para um arquivo na nuvem.',
+    'attachments_link_name' => 'Nome do Link',
+    'attachment_link' => 'Link do Anexo',
+    'attachments_link_url' => 'Link para o Arquivo',
+    'attachments_link_url_hint' => 'Url do sítio ou arquivo',
+    'attach' => 'Anexar',
+    'attachments_insert_link' => 'Adicionar Link de Anexo à Página',
+    'attachments_edit_file' => 'Editar Arquivo',
+    'attachments_edit_file_name' => 'Nome do Arquivo',
+    'attachments_edit_drop_upload' => 'Arraste arquivos para aqui ou carregue para anexar arquivos e sobrescreve-los',
+    'attachments_order_updated' => 'Ordem dos anexos atualizada',
+    'attachments_updated_success' => 'Detalhes dos anexos atualizados',
+    'attachments_deleted' => 'Anexo eliminado',
+    'attachments_file_uploaded' => 'Carregamento de arquivo efetuado com sucesso',
+    'attachments_file_updated' => 'Arquivo atualizado com sucesso',
+    'attachments_link_attached' => 'Link anexado com sucesso à página',
+    'templates' => 'Modelos',
+    'templates_set_as_template' => 'A página é um modelo',
+    'templates_explain_set_as_template' => 'Pode definir esta página como um modelo para que o seu conteúdo possa ser utilizado para criar outras páginas. Outros usuários poderão utilizar esta página como modelo se tiverem permissão para visualiza-la.',
+    'templates_replace_content' => 'Substituir conteúdo da página',
+    'templates_append_content' => 'Adicionar ao fim do conteúdo da página',
+    'templates_prepend_content' => 'Adicionar ao início do conteúdo da página',
 
     // 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' => 'Utilizador por :time',
+    'profile_created_content' => 'Conteúdo Criado',
+    'profile_not_created_pages' => ':userName não criou páginas',
+    'profile_not_created_chapters' => ':userName não criou capítulos',
+    'profile_not_created_books' => ':userName não criou livros',
+    'profile_not_created_shelves' => ':userName não criou estantes',
 
     // 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' => 'Comentário',
+    'comments' => 'Comentários',
+    'comment_add' => 'Adicionar Comentário',
+    'comment_placeholder' => 'Digite aqui os seus comentários',
+    'comment_count' => '{0} Nenhum comentário|{1} 1 Comentário|[2,*] :count Comentários',
+    'comment_save' => 'Guardar comentário',
+    'comment_saving' => 'Guardar comentário...',
+    'comment_deleting' => 'Remover comentário...',
+    'comment_new' => 'Comentário Novo',
+    'comment_created' => 'comentado :createDiff',
+    'comment_updated' => 'A editar :updateDiff por :username',
+    'comment_deleted_success' => 'Comentário removido',
+    'comment_created_success' => 'Comentário adicionado',
+    'comment_updated_success' => 'Comentário editado',
+    'comment_delete_confirm' => 'Tem a certeza de que deseja eliminar este comentário?',
+    'comment_in_reply_to' => 'Em resposta à :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.'
-];
\ No newline at end of file
+    '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.'
+];
index 06a5285f56fc4ce11e6642549a1002b1bacae698..011b5b3a25e3bfbb30a1a4b7d6d1968d146af672 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' => 'Você não tem permissão para aceder à página requisitada.',
+    'permissionJson' => 'Você não tem permissão para realizar a ação requerida.',
 
     // 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' => 'Um utilizador com o endereço de e-mail :email já existe mas com credenciais diferentes.',
+    'email_already_confirmed' => 'E-mail já foi confirmado. Tente iniciar sessão.',
+    'email_confirmation_invalid' => 'Este token de confirmação não é válido ou já foi utilizado. Por favor, tente registar-se novamente.',
+    'email_confirmation_expired' => 'O token de confirmação já expirou. Um novo e-mail foi enviado.',
+    'email_confirmation_awaiting' => 'O endereço de e-mail da conta em uso precisa ser confirmado',
+    'ldap_fail_anonymous' => 'O acesso LDAP falhou ao tentar usar o anonymous bind',
+    'ldap_fail_authed' => 'O acesso LDAP falhou ao tentar os detalhes do dn e senha fornecidos',
+    'ldap_extension_not_installed' => 'A extensão LDAP PHP não está instalada',
+    'ldap_cannot_connect' => 'Não foi possível conectar ao servidor LDAP. Conexão inicial falhou',
+    'saml_already_logged_in' => 'Sessão já iniciada',
+    'saml_user_not_registered' => 'O utilizador :name não está registado e o registo automático está desativado',
+    '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',
+    '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.',
+    'social_account_email_in_use' => 'O e-mail :email já está em uso. Se já possui uma conta poderá ligar a sua conta :socialAccount a partir das configurações do seu perfil.',
+    'social_account_existing' => 'Esta conta :socialAccount já está vinculada a este perfil.',
+    'social_account_already_used_existing' => 'Esta conta :socialAccount já está a ser utilizada por outro utilizador.',
+    'social_account_not_used' => 'Esta conta :socialAccount não está vinculada a nenhum utilizador. Por favor vincule a conta nas suas configurações de perfil. ',
+    'social_account_register_instructions' => 'Se não possui uma conta, poderá registar-se utilizando a opção :socialAccount.',
+    'social_driver_not_found' => 'Social driver não encontrado',
+    'social_driver_not_configured' => 'Os seus parâmetros sociais de :socialAccount não estão corretamente configurados.',
+    'invite_token_expired' => 'Este link de convite expirou. Alternativamente, pode tentar redefinir a senha da sua conta.',
 
     // 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' => 'O caminho do arquivo :filePath não pôde ser carregado. Certifique-se de que tem permissões de escrita no servidor.',
+    'cannot_get_image_from_url' => 'Não foi possível obter a imagem a partir de :url',
+    'cannot_create_thumbs' => 'O servidor não pôde criar as miniaturas de imagem. Por favor, verifique se a extensão GD PHP está instalada.',
+    'server_upload_limit' => 'O servidor não permite o carregamento de arquivos com esse tamanho. Por favor, tente fazer o carregamento de arquivos mais pequenos.',
+    'uploaded'  => 'O servidor não permite o carregamento de arquivos com esse tamanho. Por favor, tente fazer o carregamento de arquivos mais pequenos.',
+    'image_upload_error' => 'Ocorreu um erro no carregamento da imagem',
+    'image_upload_type_error' => 'O tipo de imagem enviada é inválida',
+    'file_upload_timeout' => 'O carregamento do arquivo expirou.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
-    'attachment_not_found' => 'Attachment not found',
+    'attachment_not_found' => 'Anexo não encontrado',
 
     // 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' => 'Falha ao tentar guardar o rascunho. Certifique-se que a conexão de Internet está funcional antes de tentar guardar esta página',
+    'page_custom_home_deletion' => 'Não é possível eliminar uma página que está definida como página inicial',
 
     // 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' => 'Entidade não encontrada',
+    'bookshelf_not_found' => 'Estante de Livros não encontrada',
+    'book_not_found' => 'Livro não encontrado',
+    'page_not_found' => 'Página não encontrada',
+    'chapter_not_found' => 'Capítulo não encontrado',
+    'selected_book_not_found' => 'O livro selecionado não foi encontrado',
+    'selected_book_chapter_not_found' => 'O Livro ou Capítulo selecionado não foi encontrado',
+    'guests_cannot_save_drafts' => 'Convidados não podem guardar rascunhos',
 
     // 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' => 'Não pode excluir o único administrador',
+    'users_cannot_delete_guest' => 'Não pode excluir o usuário convidado',
 
     // 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' => 'Este cargo não pode ser editado',
+    'role_system_cannot_be_deleted' => 'Este cargo é um cargo do sistema e não pode ser excluído',
+    'role_registration_default_cannot_delete' => 'Este cargo não poderá se excluído enquanto estiver definido como o cargo padrão',
+    'role_cannot_remove_only_admin' => 'Este utilizador é o único vinculado ao cargo de administrador. Atribua o cargo de administrador a outro antes de tentar removê-lo aqui.',
 
     // 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' => 'Ocorreu um erro ao recolher os comentários.',
+    'cannot_add_comment_to_draft' => 'Não pode adicionar comentários a um rascunho.',
+    'comment_add' => 'Ocorreu um erro ao adicionar/atualizar o comentário.',
+    'comment_delete' => 'Ocorreu um erro ao eliminar o comentário.',
+    'empty_comment' => 'Não é possível adicionar um comentário vazio.',
 
     // 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' => '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',
+    'back_soon' => 'Voltaremos em breve.',
 
     // 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' => 'Nenhum token de autorização encontrado na requisição',
+    'api_bad_authorization_format' => 'Um token de autorização foi encontrado na requisição, mas o formato parece incorreto',
+    'api_user_token_not_found' => 'Nenhum token de API correspondente foi encontrado para o token de autorização fornecido',
+    'api_incorrect_token_secret' => 'O segredo fornecido para o token de API usado está incorreto',
+    'api_user_no_api_permission' => 'O proprietário do token de API utilizado não tem permissão para fazer requisições de API',
+    'api_user_token_expired' => 'O token de autenticação expirou',
 
     // Settings & Maintenance
-    'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
+    'maintenance_test_email_failure' => 'Erro lançado ao enviar um e-mail de teste:',
 
 ];
index 85bd12fc319557dcc852fdacc07e882583d66be3..1870e34184138478210e19a2ae44f2417aa0a2ba 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'previous' => '&laquo; Previous',
-    'next'     => 'Next &raquo;',
+    'previous' => '&laquo; Anterior',
+    'next'     => 'Seguinte &raquo;',
 
 ];
index b408f3c2fdaf1e80e9cdafa36ae9507db9fbda48..e468b9f683895dee641b0f6b83781fce89d3bc3c 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' => 'As palavras-passe devem ter no mínimo oito caracteres e serem iguais à confirmação.',
+    'user' => "Não pudemos encontrar um utilizador com o endereço de e-mail fornecido.",
+    'token' => 'O token de redefinição de senha é inválido para este endereço de e-mail.',
+    'sent' => 'Enviamos o link de redefinição de palavra-passe para o seu e-mail!',
+    'reset' => 'A sua palavra-passe foi redefinida com sucesso!',
 
 ];
index f1345c743b6dcc2bdfc7555774627195ebcd4109..aa8450dbf292d449c7cb78ed3245bdc32b665d7f 100644 (file)
 return [
 
     // Common Messages
-    'settings' => 'Settings',
-    'settings_save' => 'Save Settings',
-    'settings_save_success' => 'Settings saved',
+    'settings' => 'Configurações',
+    'settings_save' => 'Guardar Configurações',
+    'settings_save_success' => 'Configurações guardadas',
 
     // 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_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' => 'Personalização',
+    'app_features_security' => 'Recursos & Segurança',
+    'app_name' => 'Nome da Aplicação',
+    'app_name_desc' => 'Este nome será mostrado no cabeçalho e em e-mails.',
+    'app_name_header' => 'Mostrar o nome no cabeçalho',
+    'app_public_access' => 'Acesso Público',
+    'app_public_access_desc' => 'Ativar esta opção irá permitir que os visitantes que não estão autenticados, acedam ao conteúdo da sua instância do BookStack.',
+    'app_public_access_desc_guest' => 'O acesso de visitantes públicos pode ser controlado através do utilizador "Convidado".',
+    'app_public_access_toggle' => 'Permitir acesso público',
+    'app_public_viewing' => 'Permitir visualização pública?',
+    'app_secure_images' => 'Carregamento de Imagens mais Seguro',
+    'app_secure_images_toggle' => 'Ativar o carregamento de imagem mais seguro',
+    'app_secure_images_desc' => 'Por razões de performance, todas as imagens são públicas. Esta opção adiciona uma string aleatória na frente das Urls de imagens. Certifique-se de que os diretórios não possam ser indexados para prevenir acesso indesejado.',
+    'app_editor' => 'Editor de Página',
+    'app_editor_desc' => 'Selecione qual editor será utilizado pelos utilizadores ao editar páginas.',
+    'app_custom_html' => 'Conteúdo personalizado para para o Head do HTML',
+    'app_custom_html_desc' => 'Quaisquer conteúdos aqui adicionados serão inseridos no final da secção <head> de cada página. Esta é uma maneira útil de sobrescrever estilos e adicionar códigos de análise de site.',
+    'app_custom_html_disabled_notice' => 'O conteúdo personalizado do <head> HTML está desativado nesta página de configurações, para garantir que quaisquer alterações que acabem maliciosas possam ser revertidas.',
+    'app_logo' => 'Logo da Aplicação',
+    'app_logo_desc' => 'A imagem deve ter 43px de altura. <br>Imagens maiores serão reduzidas.',
+    'app_primary_color' => 'Cor Primária da Aplicação',
+    'app_primary_color_desc' => 'Define a cor primária para a aplicação, incluindo o banner, botões e links.',
+    'app_homepage' => 'Página Inicial',
+    'app_homepage_desc' => 'Selecione uma opção para ser exibida como página inicial em vez da padrão. Permissões de página serão ignoradas para as páginas selecionadas.',
+    'app_homepage_select' => 'Selecione uma página',
+    'app_footer_links' => 'Links do Rodapé',
+    'app_footer_links_desc' => 'Adicionar links para mostrar dentro do rodapé do site. Estes serão exibidos no rodapé da maioria das páginas, incluindo as que não requerem autenticação. Pode utilizar uma etiqueta de "trans::<key>" para utilizar traduções definidas pelo sistema. Por exemplo: Utilizando "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é',
+    'app_disable_comments' => 'Desativar Comentários',
+    'app_disable_comments_toggle' => 'Desativar comentários',
+    'app_disable_comments_desc' => 'Desativar comentários em todas as páginas no aplicativo.<br> Comentários existentes não serão exibidos.',
 
     // 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' => 'Cores do Conteúdo',
+    'content_colors_desc' => 'Define as cores para todos os elementos da hierarquia de organização de páginas. Escolher cores com brilho similar ao das cores padrão é aconselhável para a legibilidade.',
+    'bookshelf_color' => 'Cor da Prateleira',
+    'book_color' => 'Cor do Livro',
+    'chapter_color' => 'Cor do Capítulo',
+    'page_color' => 'Cor da Página',
+    'page_draft_color' => 'Cor do Rascunho',
 
     // 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' => 'Cadastro',
+    'reg_enable' => 'Habilitar Cadastro',
+    'reg_enable_toggle' => 'Habilitar cadastro',
+    'reg_enable_desc' => 'Quando o cadastro é habilitado, visitantes poderão cadastrar-se como usuários do aplicativo. Realizado o cadastro, recebem um único cargo padrão.',
+    'reg_default_role' => 'Cargo padrão para usuários após o cadastro',
+    'reg_enable_external_warning' => 'A opção acima é ignorada enquanto a autenticação externa LDAP ou SAML estiver ativa. Contas de usuários para membros não existentes serão criadas automaticamente se a autenticação pelo sistema externo em uso for bem sucedida.',
+    'reg_email_confirmation' => 'Confirmação de E-mail',
+    'reg_email_confirmation_toggle' => 'Requerer confirmação de e-mail',
+    'reg_confirm_email_desc' => 'Em caso da restrição de domínios estar em uso, a confirmação de e-mail será requerida e esta opção será ignorada.',
+    'reg_confirm_restrict_domain' => 'Restrição de Domínios',
+    'reg_confirm_restrict_domain_desc' => 'Entre com uma lista separada por vírgulas de domínios de e-mails aos quais você deseja restringir o registo. Um e-mail de confirmação será enviado para o utilizador validar o seu respetivo endereço de e-mail antes de ser permitida a interação com a aplicação. <br> Note que os utilizadores serão capazes de alterar os seus endereços de e-mail após o sucesso na confirmação do registo.',
+    'reg_confirm_restrict_domain_placeholder' => 'Nenhuma restrição definida',
 
     // 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_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?',
-    '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' => '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_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?',
+    'maint_image_cleanup_success' => ':count imagens potencialmente não utilizadas foram encontradas e eliminadas!',
+    'maint_image_cleanup_nothing_found' => 'Nenhuma imagem por utilizar foi encontrada, nada foi eliminado!',
+    'maint_send_test_email' => 'Enviar um E-mail de Teste',
+    'maint_send_test_email_desc' => 'Esta opção envia um e-mail de teste para o endereço especificado no seu perfil.',
+    'maint_send_test_email_run' => 'Enviar e-mail de teste',
+    'maint_send_test_email_success' => 'E-mail enviado para :address',
+    'maint_send_test_email_mail_subject' => 'E-mail de Teste',
+    'maint_send_test_email_mail_greeting' => 'O envio de e-mails parece funcionar!',
+    'maint_send_test_email_mail_text' => 'Parabéns! Já que recebeu esta notificação, as suas opções de e-mail parecem estar configuradas corretamente.',
+    'maint_recycle_bin_desc' => 'Estantes, livros, capítulos e páginas eliminados são mandados para a reciclagem podendo assim ser restaurados ou excluídos permanentemente. Itens mais antigos da podem vir a ser automaticamente removidos da reciclagem após um tempo, dependendo da configuração do sistema.',
+    'maint_recycle_bin_open' => 'Abrir Reciclagem',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Restaurar',
+    'recycle_bin_contents_empty' => 'A reciclagem está atualmente vazia',
+    'recycle_bin_empty' => 'Esvaziar Reciclagem',
+    'recycle_bin_empty_confirm' => 'Isto irá destruir permanentemente todos os itens na reciclagem inclusive o conteúdo de cada item. Tem certeza de que a deseja esvaziar?',
+    'recycle_bin_destroy_confirm' => 'Esta ação irá excluir permanentemente do sistema este item junto com todos os elementos filhos listados abaixo. Não poderá restaurar este conteúdo. Tem certeza de que deseja excluir permanentemente este item?',
+    'recycle_bin_destroy_list' => 'Itens a serem Destruídos',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Registo de auditoria',
+    'audit_desc' => 'Este registo de auditoria exibe uma lista de atividades rastreadas no sistema. Esta lista não é filtrada ao contrário de listas de atividades semelhantes no sistema onde os filtros de permissão são aplicados.',
+    'audit_event_filter' => 'Filtro de Evento',
+    'audit_event_filter_no_filter' => 'Sem filtro',
+    'audit_deleted_item' => 'Item excluído',
+    'audit_deleted_item_name' => 'Nome: :name',
+    'audit_table_user' => 'Utilizador',
+    'audit_table_event' => 'Evento',
+    'audit_table_related' => 'Item ou Detalhe Relacionado',
+    'audit_table_ip' => 'IP Address',
+    'audit_table_date' => 'Data da Atividade',
+    'audit_date_from' => 'Intervalo De',
+    'audit_date_to' => 'Intervalo Até',
 
     // 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',
-    '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' => 'Cargos',
+    'role_user_roles' => 'Cargos de Utilizador',
+    'role_create' => 'Criar novo Cargo',
+    'role_create_success' => 'Cargo criado com sucesso',
+    'role_delete' => 'Excluir Cargo',
+    'role_delete_confirm' => 'A ação vai eliminar o cargo de nome \':roleName\'.',
+    'role_delete_users_assigned' => 'Esse cargo tem :userCount utilizadores vinculados nele. Se quiser migrar utilizadores deste cargo para outro, selecione um novo cargo.',
+    'role_delete_no_migration' => "Não migrar utilizadores",
+    'role_delete_sure' => 'Tem certeza que deseja excluir este cargo?',
+    'role_delete_success' => 'Cargo excluído com sucesso',
+    'role_edit' => 'Editar Cargo',
+    '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' => 'Gerir utilizadores',
+    'role_manage_roles' => 'Gerir cargos e permissões de cargos',
+    'role_manage_entity_permissions' => 'Gerir todos os livros, capítulos e permissões de páginas',
+    'role_manage_own_entity_permissions' => 'Gerir permissões de seu próprio livro, capítulo e paginas',
+    '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' => '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 utilizador altere os seus próprios privilégios ou privilégios de outros no sistema. Apenas atribua cargos com essas permissões a utilizadores de confiança.',
+    'role_asset_desc' => 'Estas permissões controlam o acesso padrão para os ativos dentro do sistema. Permissões em Livros, Capítulos e Páginas serão sobrescritas por estas permissões.',
+    'role_asset_admins' => 'Os administradores recebem automaticamente acesso a todo o conteúdo, mas estas opções podem mostrar ou ocultar as opções da Interface de Usuário.',
+    'role_all' => 'Todos',
+    'role_own' => 'Próprio',
+    'role_controlled_by_asset' => 'Controlado pelo ativo para o qual eles são enviados',
+    'role_save' => 'Guardar Cargo',
+    'role_update_success' => 'Cargo atualizado com sucesso',
+    'role_users' => 'Utilizadores com este cargo',
+    'role_users_none' => 'Nenhum utilizador está atualmente vinculado a este cargo',
 
     // Users
-    'users' => 'Users',
-    'user_profile' => 'User Profile',
-    'users_add_new' => 'Add New User',
-    'users_search' => 'Search Users',
-    '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_delete_success' => 'Users 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' => 'Utilizadores',
+    'user_profile' => 'Perfil do Utilizador',
+    'users_add_new' => 'Adicionar Novo Utilizador',
+    'users_search' => 'Pesquisar Utilizadores',
+    'users_latest_activity' => 'Última atividade',
+    'users_details' => 'Detalhes do Utilizador',
+    'users_details_desc' => 'Defina um nome de exibição e um endereço de e-mail para este utilizador. O endereço de e-mail será utilizado na autenticação da aplicação.',
+    'users_details_desc_no_email' => 'Defina um nome de exibição para este utilizador para que outros possam reconhecê-lo.',
+    '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_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',
+    'users_external_auth_id_desc' => 'Este ID é utilizado para relacionar um utilizador ao comunicar com um sistema de autenticação externo.',
+    'users_password_warning' => 'Apenas preencha os dados abaixo caso queira modificar a sua palavra-passe.',
+    'users_system_public' => 'Este utilizador representa quaisquer convidados que visitam a aplicação. Não pode ser utilizado para efetuar autenticação mas é automaticamente atribuído.',
+    'users_delete' => 'Eliminar Utilizador',
+    'users_delete_named' => 'Eliminar :userName',
+    'users_delete_warning' => 'A ação vai eliminar completamente o utilizador de nome \':userName\' do sistema.',
+    'users_delete_confirm' => 'Tem certeza que eliminar este utilizador?',
+    'users_migrate_ownership' => 'Migrar Posse',
+    'users_migrate_ownership_desc' => 'Selecione um utilizador aqui se desejar que outro se torne o proprietário de todos os itens atualmente pertencentes a este.',
+    'users_none_selected' => 'Nenhum utilizador selecionado',
+    'users_delete_success' => 'Utilizador removido com sucesso',
+    'users_edit' => 'Editar Utilizador',
+    'users_edit_profile' => 'Editar Perfil',
+    'users_edit_success' => 'Utilizador atualizado com sucesso',
+    'users_avatar' => 'Avatar do Utilizador',
+    'users_avatar_desc' => 'Defina uma imagem para representar este utilizador. Deve ser um quadrado com aproximadamente 256px de altura e largura.',
+    'users_preferred_language' => 'Linguagem de Preferência',
+    'users_preferred_language_desc' => 'Esta opção irá alterar o idioma utilizado para a interface de utilizador da aplicação. Isto não afetará nenhum conteúdo criado por utilizadores.',
+    'users_social_accounts' => 'Contas Sociais',
+    'users_social_accounts_info' => 'Aqui pode ligar outras contas para acesso mais rápido. Desligar uma conta não retira a possibilidade de acesso usando-a. Para revogar o acesso ao perfil através da conta social, você deverá fazê-lo na sua conta social.',
+    'users_social_connect' => 'Contas Associadas',
+    'users_social_disconnect' => 'Dissociar Conta',
+    'users_social_connected' => 'A conta:socialAccount foi associada com sucesso ao seu perfil.',
+    'users_social_disconnected' => 'A conta:socialAccount foi dissociada com sucesso de seu perfil.',
+    'users_api_tokens' => 'Tokens de API',
+    'users_api_tokens_none' => 'Nenhum token de API foi criado para este utilizador',
+    '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' => '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' => 'Criar Token de API',
+    'user_api_token_name' => 'Nome',
+    'user_api_token_name_desc' => 'Dê ao seu token um nome legível como um futuro lembrete de seu propósito.',
+    'user_api_token_expiry' => 'Data de Expiração',
+    'user_api_token_expiry_desc' => 'Defina uma data em que este token expira. Depois desta data, as requisições feitas usando este token deixarão de funcionar. Deixar este campo em branco definirá um prazo de 100 anos futuros.',
+    'user_api_token_create_secret_message' => 'Imediatamente após a criação deste token, um "ID de token" e "Segredo de token" serão gerados e exibidos. O segredo só será mostrado uma única vez, portanto, certifique-se de copiar o valor para algum lugar seguro antes de prosseguir.',
+    'user_api_token_create_success' => 'Token de API criado com sucesso',
+    'user_api_token_update_success' => 'Token de API atualizado com sucesso',
+    'user_api_token' => 'Token de API',
+    'user_api_token_id' => 'ID do Token',
+    'user_api_token_id_desc' => 'Este é um identificador de sistema não editável, gerado para este token, que precisará ser fornecido em solicitações de API.',
+    'user_api_token_secret' => 'Segredo do Token',
+    'user_api_token_secret_desc' => 'Este é um segredo de sistema gerado para este token que precisará ser fornecido em requisições de API. Isto só será mostrado nesta única vez, portanto, copie este valor para um lugar seguro.',
+    'user_api_token_created' => 'Token criado a :timeAgo',
+    'user_api_token_updated' => 'Token atualizado a :timeAgo',
+    'user_api_token_delete' => 'Eliminar Token',
+    'user_api_token_delete_warning' => 'Isto irá excluir completamente este token de API com o nome \':tokenName\' do sistema.',
+    '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',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Catalão',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 76b57a2a3b58ddb8ef41e0562c5187359cc6e542..30033fce8f5593214962fb43010aa508bc585ed3 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'             => 'O campo :attribute deve ser aceite.',
+    'active_url'           => 'O campo :attribute não é um URL válido.',
+    'after'                => 'O campo :attribute deve ser uma data posterior à data :date.',
+    'alpha'                => 'O campo :attribute deve conter apenas letras.',
+    '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'         => 'The provided code is not valid or has already been used.',
+    'before'               => 'O campo :attribute deve ser uma data anterior à data :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' => 'O campo :attribute deve estar entre :min e :max.',
+        'file'    => 'O campo :attribute deve ter entre :min e :max kilobytes.',
+        'string'  => 'O campo :attribute deve ter entre :min e :max caracteres.',
+        'array'   => 'O campo :attribute deve ter entre :min e :max itens.',
     ],
-    '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'              => 'O campo :attribute deve ser verdadeiro ou falso.',
+    'confirmed'            => 'O campo :attribute não é igual à sua confirmação.',
+    'date'                 => 'O campo :attribute não está num formato de data válido.',
+    'date_format'          => 'O campo :attribute não tem a formatação :format.',
+    'different'            => 'O campo :attribute e o campo :other devem ser diferentes.',
+    'digits'               => 'O campo :attribute deve ter :digits dígitos.',
+    'digits_between'       => 'O campo :attribute deve ter entre :min e :max dígitos.',
+    'email'                => 'O campo :attribute deve ser um endereço de e-mail válido.',
+    'ends_with' => 'O campo :attribute deve terminar com um dos seguintes: :values',
+    'filled'               => 'O campo :attribute é requerido.',
     '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' => 'O campo :attribute deve ser maior que :value.',
+        'file'    => 'O campo :attribute deve ser maior que :value kilobytes.',
+        'string'  => 'O campo :attribute deve ser maior que :value caracteres.',
+        'array'   => 'O campo :attribute deve ter mais que :value itens.',
     ],
     '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' => 'O campo :attribute deve ser maior ou igual a :value.',
+        'file'    => 'O campo :attribute deve ser maior ou igual a :value kilobytes.',
+        'string'  => 'O campo :attribute deve ser maior ou igual a :value caracteres.',
+        'array'   => 'O campo :attribute deve ter :value itens ou mais.',
     ],
-    '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'               => 'O campo :attribute selecionado não é válido.',
+    'image'                => 'O campo :attribute deve ser uma imagem.',
+    'image_extension'      => 'O campo :attribute deve ter uma extensão de imagem válida e suportada.',
+    'in'                   => 'O campo :attribute selecionado não é válido.',
+    'integer'              => 'O campo :attribute deve ser um número inteiro.',
+    'ip'                   => 'O campo :attribute deve ser um endereço IP válido.',
+    'ipv4'                 => 'O campo :attribute deve ser um endereço IPv4 válido.',
+    'ipv6'                 => 'O campo :attribute deve ser um endereço IPv6 válido.',
+    'json'                 => 'O campo :attribute deve ser uma string JSON válida.',
     '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' => 'O campo :attribute deve ser menor que :value.',
+        'file'    => 'O campo :attribute deve ser menor que :value kilobytes.',
+        'string'  => 'O campo :attribute deve ser menor que :value caracteres.',
+        'array'   => 'O campo :attribute deve conter menos que :value itens.',
     ],
     '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' => 'O campo :attribute deve ser menor ou igual a :value.',
+        'file'    => 'O campo :attribute deve ser menor ou igual a :value kilobytes.',
+        'string'  => 'O campo :attribute deve ser menor ou igual a :value caracteres.',
+        'array'   => 'O campo :attribute não deve conter mais que :value itens.',
     ],
     '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' => 'O valor para o campo :attribute não deve ser maior que :max.',
+        'file'    => 'O valor para o campo :attribute não deve ter tamanho maior que :max kilobytes.',
+        'string'  => 'O valor para o campo :attribute não deve ter mais que :max caracteres.',
+        'array'   => 'O valor para o campo :attribute não deve ter mais que :max itens.',
     ],
-    'mimes'                => 'The :attribute must be a file of type: :values.',
+    'mimes'                => 'O campo :attribute deve ser do tipo type: :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' => 'O campo :attribute não deve ser menor que :min.',
+        'file'    => 'O campo :attribute não deve ter tamanho menor que :min kilobytes.',
+        'string'  => 'O campo :attribute não deve ter menos que :min caracteres.',
+        'array'   => 'O campo :attribute não deve ter menos que :min itens.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
-    '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.',
+    'not_in'               => 'O campo selecionado :attribute é inválido.',
+    'not_regex'            => 'O formato do campo :attribute é inválido.',
+    'numeric'              => 'O campo :attribute deve ser um número.',
+    'regex'                => 'O formato do campo :attribute é inválido.',
+    'required'             => 'O campo :attribute é requerido.',
+    'required_if'          => 'O campo :attribute é requerido quando o campo :other tem valor :value.',
+    'required_with'        => 'O campo :attribute é requerido quando os valores :values estiverem presentes.',
+    'required_with_all'    => 'O campo :attribute é requerido quando os valores :values estiverem presentes.',
+    'required_without'     => 'O campo :attribute é requerido quando os valores :values não estiverem presentes.',
+    'required_without_all' => 'O campo :attribute é requerido quando nenhum dos valores :values estiverem presentes.',
+    'same'                 => 'O campo :attribute e o campo :other devem ser iguais.',
+    'safe_url'             => 'O link fornecido poderá não ser seguro.',
     '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' => 'O tamanho do campo :attribute deve ser :size.',
+        'file'    => 'O tamanho do arquivo :attribute deve ser de :size kilobytes.',
+        'string'  => 'O tamanho do campo :attribute deve ser de :size caracteres.',
+        'array'   => 'O campo :attribute deve conter :size itens.',
     ],
-    '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'               => 'O campo :attribute deve ser uma string.',
+    'timezone'             => 'O campo :attribute deve conter uma timezone válida.',
+    'totp'                 => 'The provided code is not valid or has expired.',
+    '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.',
 
     // Custom validation lines
     'custom' => [
         'password-confirm' => [
-            'required_with' => 'Password confirmation required',
+            'required_with' => 'Confirmação de senha requerida',
         ],
     ],
 
index 9789fe3bee23e5e6ff3a5d14de0ecbf952314708..487a6fce6d6171dd09b5486f92c09dc82ed46ce6 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'excluiu a prateleira',
     'bookshelf_delete_notification'    => 'Prateleira excluída com sucesso',
 
+    // 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',
+
     // Other
     'commented_on'                => 'comentou em',
+    'permissions_update'          => 'atualizou permissões',
 ];
index 094cdf4d2a423edcbdcc813b3aa9212e3655108f..a5f0c18bc21bc98b4eafe11741f56cb384d01dd1 100644 (file)
@@ -43,7 +43,7 @@ return [
     '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.',
     'reset_password_send_button' => 'Enviar o Link de Redefinição',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'Um link de redefinição de senha será enviado para :email se o endereço de e-mail for encontrado no sistema.',
     'reset_password_success' => 'Sua senha foi redefinida com sucesso.',
     'email_reset_subject' => 'Redefina a senha de :appName',
     'email_reset_text' => 'Você recebeu esse e-mail pois recebemos uma solicitação de redefinição de senha para a sua conta.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Senha definida, você agora tem acesso a :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.',
 ];
\ No newline at end of file
index e57467ec11a9b93415fde5ac90058bff073cfd77..1435a380d6087bb8bdd623479b1fa74cfdded1e2 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Copiar',
     'reply' => 'Responder',
     'delete' => 'Excluir',
+    'delete_confirm' => 'Confirmar Exclusão',
     'search' => 'Pesquisar',
     'search_clear' => 'Limpar Pesquisa',
     'reset' => 'Redefinir',
     'remove' => 'Remover',
     'add' => 'Adicionar',
+    'configure' => 'Configure',
     'fullscreen' => 'Tela cheia',
+    'favourite' => 'Favoritos',
+    'unfavourite' => 'Remover dos Favoritos',
+    'next' => 'Seguinte',
+    'previous' => 'Anterior',
 
     // Sort Options
     'sort_options' => 'Opções de Ordenação',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Ordenação Crescente',
     'sort_descending' => 'Ordenação Decrescente',
     'sort_name' => 'Nome',
+    'sort_default' => 'Padrão',
     'sort_created_at' => 'Data de Criação',
     'sort_updated_at' => 'Data de Atualização',
 
@@ -54,6 +61,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,17 +71,25 @@ return [
     'breadcrumb' => 'Caminho',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Menu de Perfil',
     'view_profile' => 'Visualizar Perfil',
     'edit_profile' => 'Editar Perfil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Modo Escuro',
+    'light_mode' => 'Modo Claro',
 
     // Layout tabs
     'tab_info' => 'Informações',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Conteúdo',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // 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:',
     'email_rights' => 'Todos os direitos reservados',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Políticas de Privacidade',
+    'terms_of_service' => 'Termos de Serviço',
 ];
index e4510145294c1cd6a6cc0160dd02c69954d32566..a1210d52ce99c92a784e66a063fdb48a53e76a77 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Carregar Mais',
     'image_image_name' => 'Nome da Imagem',
     'image_delete_used' => 'Essa imagem é usada nas páginas abaixo.',
-    'image_delete_confirm' => 'Clique em Excluir novamente para confirmar que você deseja mesmo eliminar a imagem.',
+    'image_delete_confirm_text' => 'Tem certeza de que deseja excluir essa imagem?',
     'image_select_image' => 'Selecionar Imagem',
     'image_dropzone' => 'Arraste imagens ou clique aqui para fazer upload',
     'images_deleted' => 'Imagens Excluídas',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Editar Código',
     'code_language' => 'Linguagem do Código',
     'code_content' => 'Código',
+    'code_session_history' => 'Histórico de Sessão',
     'code_save' => 'Salvar Código',
 ];
index 323ce083f75a616d45ee6f6c0fa84043f4ff0258..ad58879b5768f1c722853b1379975b9592cd0c2b 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Criado :timeLength por :user',
     'meta_updated' => 'Atualizado :timeLength',
     'meta_updated_name' => 'Atualizado :timeLength por :user',
+    'meta_owned_name' => 'De :user',
     'entity_select' => 'Seleção de Entidade',
     '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',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Arquivo Web Contained',
     'export_pdf' => 'Arquivo PDF',
     'export_text' => 'Arquivo Texto',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Permissões',
     'permissions_intro' => 'Uma vez habilitadas, estas permissões terão prioridade sobre outro conjunto de permissões.',
     'permissions_enable' => 'Habilitar Permissões Customizadas',
     'permissions_save' => 'Salvar Permissões',
+    'permissions_owner' => 'Proprietário',
 
     // Search
     'search_results' => 'Resultado(s) da Pesquisa',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Nenhuma página corresponde à pesquisa',
     'search_for_term' => 'Pesquisar por :term',
     'search_more' => 'Mais Resultados',
-    'search_filters' => 'Filtros de Pesquisa',
+    'search_advanced' => 'Pesquisa Avançada',
+    'search_terms' => 'Termos da Pesquisa',
     'search_content_type' => 'Tipo de Conteúdo',
     'search_exact_matches' => 'Correspondências Exatas',
     'search_tags' => 'Persquisar Tags',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Permissão definida',
     'search_created_by_me' => 'Criado por mim',
     'search_updated_by_me' => 'Atualizado por mim',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => 'Opções de Data',
     'search_updated_before' => 'Atualizado antes de',
     'search_updated_after' => 'Atualizado depois de',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Criar Novo Capítulo',
     'chapters_delete' => 'Excluir Capítulo',
     'chapters_delete_named' => 'Excluir Capítulo :chapterName',
-    'chapters_delete_explain' => 'A ação vai excluir o capítulo de nome \':chapterName\'. Todas as páginas do capítulo serão removidas e adicionadas diretamente ao livro pai.',
+    'chapters_delete_explain' => 'Isto irá excluir o capítulo com o nome \':chapterName\'. Todas as páginas que existem neste capítulo também serão excluídas.',
     'chapters_delete_confirm' => 'Tem certeza que deseja excluir o capítulo?',
     'chapters_edit' => 'Editar Capítulo',
     'chapters_edit_named' => 'Editar Capítulo :chapterName',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Revisões da Página',
     'pages_revisions_named' => 'Revisões de Página para :pageName',
     'pages_revision_named' => 'Revisão de Página para :pageName',
+    'pages_revision_restored_from' => 'Restaurado de #:id; :summary',
     'pages_revisions_created_by' => 'Criada por',
     'pages_revisions_date' => 'Data da Revisão',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Upload de Arquivos',
     'attachments_link' => 'Links Anexados',
     'attachments_set_link' => 'Definir Link',
-    'attachments_delete_confirm' => 'Clique novamente em Excluir para confirmar a exclusão desse anexo.',
+    'attachments_delete' => 'Tem certeza de que deseja excluir esse anexo?',
     'attachments_dropzone' => 'Arraste arquivos para cá ou clique para anexar arquivos',
     'attachments_no_files' => 'Nenhum arquivo foi enviado',
     'attachments_explain_link' => 'Você pode anexar um link se preferir não fazer o upload do arquivo. O link poderá ser para uma outra página ou para um arquivo na nuvem.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Link para o Arquivo',
     'attachments_link_url_hint' => 'URL do site ou arquivo',
     'attach' => 'Anexar',
+    'attachments_insert_link' => 'Adicionar Link de Anexo à Página',
     'attachments_edit_file' => 'Editar Arquivo',
     'attachments_edit_file_name' => 'Nome do Arquivo',
     'attachments_edit_drop_upload' => 'Arraste arquivos para cá ou clique para anexar arquivos e sobrescreve-los',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index 0758c3e978ce0144ab25ec1795efe72ea8e8e9ff..d0e5c443904a186dbe1199c59d68e45696a3e4ad 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'O upload do arquivo expirou.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Erro de \'Page mismatch\' durante a atualização do anexo',
     'attachment_not_found' => 'Anexo não encontrado',
 
     // Pages
@@ -84,6 +83,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 07865c43dd1345f68b3d95437d6bd4295a489213..fde9e2937e4bf04c0e784cd5f89da77be1fd046e 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Senhas devem ter ao menos oito caracteres e ser iguais à confirmação.',
     'user' => "Não pudemos encontrar um usuário com o e-mail fornecido.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'O token de redefinição de senha é inválido para este endereço de e-mail.',
     'sent' => 'Enviamos o link de redefinição de senha para o seu e-mail!',
     'reset' => 'Sua senha foi redefinida com sucesso!',
 
index 7176eb9f37e5064984226739e45608388de2e11a..c5b113da3f7d5b029c772e6f71969c9c979750ad 100644 (file)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Página Inicial',
     'app_homepage_desc' => 'Selecione uma opção para ser exibida como página inicial em vez da padrão. Permissões de página serão ignoradas para as páginas selecionadas.',
     'app_homepage_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_label' => 'Etiqueta do Link',
+    'app_footer_links_url' => 'URL do Link',
+    'app_footer_links_add' => 'Adicionar Link de Rodapé',
     'app_disable_comments' => 'Desativar Comentários',
     'app_disable_comments_toggle' => 'Desativar comentários',
     'app_disable_comments_desc' => 'Desativar comentários em todas as páginas no aplicativo.<br> Comentários existentes não serão exibidos.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignorar imagens em revisões',
+    '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?',
     'maint_image_cleanup_success' => ':count imagens potencialmente não utilizadas foram encontradas e excluídas!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'E-mail de Teste',
     'maint_send_test_email_mail_greeting' => 'O envio de e-mails parece funcionar!',
     'maint_send_test_email_mail_text' => 'Parabéns! Já que você recebeu esta notificação, suas opções de e-mail parecem estar configuradas corretamente.',
+    'maint_recycle_bin_desc' => 'Prateleiras, livros, capítulos e páginas deletados são mandados para a lixeira podendo assim ser restaurados ou excluídos permanentemente. Itens mais antigos da lixeira podem vir a ser automaticamente removidos da lixeira após um tempo dependendo da configuração do sistema.',
+    'maint_recycle_bin_open' => 'Abrir Lixeira',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Restaurar',
+    'recycle_bin_contents_empty' => 'A lixeira está vazia',
+    'recycle_bin_empty' => 'Esvaziar Lixeira',
+    'recycle_bin_empty_confirm' => 'Isso irá destruir permanentemente todos os itens na lixeira inclusive o conteúdo de cada item. Tem certeza de que quer esvaziar a lixeira?',
+    'recycle_bin_destroy_confirm' => 'Esta ação irá excluir permanentemente do sistema este item junto com todos os elementos filhos listados abaixo. Você não poderá restaurar esse conteúdo. Tem certeza de que deseja excluir permanentemente este item?',
+    'recycle_bin_destroy_list' => 'Itens a serem Destruídos',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Registro de auditoria',
+    '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' => 'Filtro de Eventos',
+    'audit_event_filter_no_filter' => 'Sem filtro',
+    'audit_deleted_item' => 'Item excluído',
+    'audit_deleted_item_name' => 'Nome: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Cargos',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Administradores recebem automaticamente acesso a todo o conteúdo, mas essas opções podem mostrar ou ocultar as opções da Interface de Usuário.',
     'role_all' => 'Todos',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Perfil do Usuário',
     'users_add_new' => 'Adicionar Novo Usuário',
     'users_search' => 'Pesquisar Usuários',
+    'users_latest_activity' => 'Última Atividade',
     'users_details' => 'Detalhes do Usuário',
     'users_details_desc' => 'Defina um nome de exibição e um endereço de e-mail para este usuário. O endereço de e-mail será usado para fazer login na aplicação.',
     'users_details_desc_no_email' => 'Defina um nome de exibição para este usuário para que outros usuários possam reconhecê-lo',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => 'Excluir :userName',
     'users_delete_warning' => 'A ação vai excluir completamente o usuário de nome \':userName\' do sistema.',
     'users_delete_confirm' => 'Tem certeza que deseja excluir esse usuário?',
-    'users_delete_success' => 'Usuários excluídos com sucesso',
+    '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' => 'Nenhum usuário selecionado',
+    'users_delete_success' => 'Usuário removido com sucesso',
     'users_edit' => 'Editar Usuário',
     'users_edit_profile' => 'Editar Perfil',
     'users_edit_success' => 'Usuário atualizado com sucesso',
@@ -157,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',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 718fca6b9323894f0afaf96d8958f1f0fcb58ca1..4bf85d7cfd1bad2cb18974b1222f6658cb5de750 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'         => 'The provided code is not valid or has already been used.',
     'before'               => 'O campo :attribute deve ser uma data anterior à data :date.',
     'between'              => [
         'numeric' => 'O campo :attribute deve estar entre :min e :max.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'O campo :attribute não deve ter menos que :min caracteres.',
         'array'   => 'O campo :attribute não deve ter menos que :min itens.',
     ],
-    'no_double_extension'  => 'O campo :attribute deve ter apenas uma extensão de arquivo.',
     'not_in'               => 'O campo selecionado :attribute é inválido.',
     'not_regex'            => 'O formato do campo :attribute é inválido.',
     'numeric'              => 'O campo :attribute deve ser um número.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'O campo :attribute é requerido quando os valores :values não estiverem presentes.',
     'required_without_all' => 'O campo :attribute é requerido quando nenhum dos valores :values estiverem presentes.',
     'same'                 => 'O campo :attribute e o campo :other devem ser iguais.',
+    'safe_url'             => 'O link fornecido pode não ser seguro.',
     'size'                 => [
         'numeric' => 'O tamanho do campo :attribute deve ser :size.',
         'file'    => 'O tamanho do arquivo :attribute deve ser de :size kilobytes.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => 'O campo :attribute deve ser uma string.',
     'timezone'             => 'O campo :attribute deve conter uma timezone válida.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     '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 06b1e354b1e8948e5efe147be7d7180824208ea5..0a43afc5a543039cf9019fdb3e0cd240cec82147 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'удалил полку',
     'bookshelf_delete_notification'    => 'Полка успешно удалена',
 
+    // Favourites
+    'favourite_add_notification' => '":name" добавлено в избранное',
+    'favourite_remove_notification' => '":name" удалено из избранного',
+
+    // MFA
+    'mfa_setup_method_notification' => 'Двухфакторный метод авторизации успешно настроен',
+    'mfa_remove_method_notification' => 'Двухфакторный метод авторизации успешно удален',
+
     // Other
     'commented_on'                => 'прокомментировал',
+    'permissions_update'          => 'обновил разрешения',
 ];
index 1f0ec6b802d707e4f41c16f3120619bfce72f6a1..8410e40e4503771e688df1be1bd5474305224262 100644 (file)
@@ -73,5 +73,40 @@ return [
     'user_invite_page_welcome' => 'Добро пожаловать в :appName!',
     'user_invite_page_text' => 'Завершите настройку аккаунта, установите пароль для дальнейшего входа в :appName.',
     'user_invite_page_confirm_button' => 'Подтвердите пароль',
-    'user_invite_success' => 'Пароль установлен, теперь у вас есть доступ к :appName!'
+    'user_invite_success' => 'Пароль установлен, теперь у вас есть доступ к :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' => 'Двухфакторный метод настроен, пожалуйста, войдите снова, используя сконфигурированный метод.',
 ];
\ No newline at end of file
index 12fac6507f82469f7f432f8ed5b46a5ca4372427..6e2a3193121d495bde1934c54cd424abbf25904e 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Скопировать',
     'reply' => 'Ответить',
     'delete' => 'Удалить',
+    'delete_confirm' => 'Подтвердить удаление',
     'search' => 'Поиск',
     'search_clear' => 'Очистить поиск',
     'reset' => 'Сбросить',
     'remove' => 'Удалить',
     'add' => 'Добавить',
+    'configure' => 'Configure',
     'fullscreen' => 'На весь экран',
+    'favourite' => 'Избранное',
+    'unfavourite' => 'Убрать из избранного',
+    'next' => 'Следующая',
+    'previous' => 'Предыдущая',
 
     // Sort Options
     'sort_options' => 'Параметры сортировки',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'По возрастанию',
     'sort_descending' => 'По убыванию',
     'sort_name' => 'По имени',
+    'sort_default' => 'По умолчанию',
     'sort_created_at' => 'По дате создания',
     'sort_updated_at' => 'По дате обновления',
 
@@ -54,6 +61,7 @@ return [
     'no_activity' => 'Нет действий для просмотра',
     'no_items' => 'Нет доступных элементов',
     'back_to_top' => 'Наверх',
+    'skip_to_main_content' => 'Перейти к основному контенту',
     'toggle_details' => 'Подробности',
     'toggle_thumbnails' => 'Миниатюры',
     'details' => 'Детали',
@@ -63,6 +71,7 @@ return [
     'breadcrumb' => 'Навигация',
 
     // Header
+    'header_menu_expand' => 'Развернуть меню заголовка',
     'profile_menu' => 'Меню профиля',
     'view_profile' => 'Посмотреть профиль',
     'edit_profile' => 'Редактировать профиль',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Информация',
+    'tab_info_label' => 'Вкладка: Показать вторичную информацию',
     'tab_content' => 'Содержание',
+    'tab_content_label' => 'Вкладка: Показать основной контент',
 
     // Email Content
     'email_action_help' => 'Если у вас возникли проблемы с нажатием кнопки \':actionText\', то скопируйте и вставьте указанный URL-адрес в свой браузер:',
     'email_rights' => 'Все права защищены',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Политика конфиденциальности',
+    'terms_of_service' => 'Условия использования',
 ];
index d85fca0084358546f89ba300745cdecd58bb3b5f..12b1dd7cfbdd13723381068facf0aa7a66650953 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Загрузить еще',
     'image_image_name' => 'Название изображения',
     'image_delete_used' => 'Это изображение используется на странице ниже.',
-    'image_delete_confirm' => 'Нажмите \'Удалить\' еще раз для подтверждения удаления.',
+    'image_delete_confirm_text' => 'Вы уверены, что хотите удалить это изображение?',
     'image_select_image' => 'Выбрать изображение',
     'image_dropzone' => 'Перетащите изображение или кликните для загрузки',
     'images_deleted' => 'Изображения удалены',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Изменить код',
     'code_language' => 'Язык кода',
     'code_content' => 'Содержимое кода',
+    'code_session_history' => 'История сессии',
     'code_save' => 'Сохранить код',
 ];
index fc180061a86e20ae8f4ebb947b658de1dd708fa7..42b06931c340cd121c06f0809a50add1eeb93eb5 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => ':user создал :timeLength',
     'meta_updated' => 'Обновлено :timeLength',
     'meta_updated_name' => ':user обновил :timeLength',
+    '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' => 'Нет недавно обновленных страниц',
@@ -33,12 +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' => 'Владелец',
 
     // Search
     'search_results' => 'Результаты поиска',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Нет страниц, соответствующих этому поиску',
     'search_for_term' => 'Искать :term',
     'search_more' => 'Еще результаты',
-    'search_filters' => 'Фильтры поиска',
+    'search_advanced' => 'Расширенный поиск',
+    'search_terms' => 'Поисковые запросы',
     'search_content_type' => 'Тип содержимого',
     'search_exact_matches' => 'Точные соответствия',
     'search_tags' => 'Поиск по тегам',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Набор разрешений',
     'search_created_by_me' => 'Создано мной',
     'search_updated_by_me' => 'Обновлено мной',
+    'search_owned_by_me' => 'Созданные мной',
     'search_date_options' => 'Параметры даты',
     'search_updated_before' => 'Обновлено до',
     'search_updated_after' => 'Обновлено после',
@@ -92,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' => 'Это применит текущие настройки доступов этой книжной полки ко всем книгам, содержащимся внутри. Перед активацией убедитесь, что все изменения в доступах этой книжной полки сохранены.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Создать новую главу',
     'chapters_delete' => 'Удалить главу',
     'chapters_delete_named' => 'Удалить главу :chapterName',
-    'chapters_delete_explain' => 'Это удалит главу с именем \':chapterName\'. Все страницы главы будут удалены и перемещены напрямую в книгу.',
+    'chapters_delete_explain' => 'Это действие удалит главу с названием \':chapterName\'. Все страницы, которые существуют в этой главе, также будут удалены.',
     'chapters_delete_confirm' => 'Вы действительно хотите удалить эту главу?',
     'chapters_edit' => 'Редактировать главу',
     'chapters_edit_named' => 'Редактировать главу :chapterName',
@@ -207,6 +215,7 @@ return [
     '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' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Загрузить файл',
     'attachments_link' => 'Присоединить ссылку',
     'attachments_set_link' => 'Установить ссылку',
-    'attachments_delete_confirm' => 'Нажмите \'Удалить\' еще раз, чтобы подтвердить удаление этого файла.',
+    'attachments_delete' => 'Вы уверены, что хотите удалить это вложение?',
     'attachments_dropzone' => 'Перетащите файл сюда или нажмите здесь, чтобы загрузить файл',
     'attachments_no_files' => 'Файлы не загружены',
     'attachments_explain_link' => 'Вы можете присоединить ссылку, если вы предпочитаете не загружать файл. Это может быть ссылка на другую страницу или ссылка на файл в облаке.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Ссылка на файл',
     'attachments_link_url_hint' => 'URL-адрес сайта или файла',
     'attach' => 'Прикрепить',
+    'attachments_insert_link' => 'Добавить ссылку на вложение',
     'attachments_edit_file' => 'Редактировать файл',
     'attachments_edit_file_name' => 'Название файла',
     'attachments_edit_drop_upload' => 'Перетащите файлы или нажмите здесь, чтобы загрузить и перезаписать',
@@ -311,4 +321,4 @@ return [
     'revision_restore_confirm' => 'Вы уверены, что хотите восстановить эту версию? Текущее содержимое страницы будет заменено.',
     'revision_delete_success' => 'Версия удалена',
     'revision_cannot_delete_latest' => 'Нельзя удалить последнюю версию.'
-];
\ No newline at end of file
+];
index 0cc4a72b147727ca9acbbfbfec852852004f8f6c..96c792e1dd828cd1b95c8536bc051a85c23f851a 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Время загрузки файла истекло.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Несоответствие страницы во время обновления вложения',
     'attachment_not_found' => 'Вложение не найдено',
 
     // Pages
@@ -84,6 +83,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 0981aaa734dc8ea52a1d4918a0c07bf84fa8b697..e4bd8534094c9506432a34d772ca54332d27d35b 100755 (executable)
@@ -29,7 +29,7 @@ return [
     'app_editor_desc' => 'Выберите, какой редактор будет использоваться всеми пользователями для редактирования страниц.',
     'app_custom_html' => 'Пользовательский контент заголовка HTML',
     'app_custom_html_desc' => 'Любой контент, добавленный здесь, будет вставлен в нижнюю часть раздела <head> каждой страницы. Это удобно для переопределения стилей или добавления кода аналитики.',
-    'app_custom_html_disabled_notice' => 'Пользовательский контент заголовка HTML отключен на этой странице, чтобы гарантировать отмену любых критических изменений',
+    'app_custom_html_disabled_notice' => 'Пользовательский контент заголовка HTML отключен на этой странице, чтобы гарантировать отмену любых критических изменений.',
     'app_logo' => 'Логотип приложения',
     'app_logo_desc' => 'Это изображение должно быть 43px в высоту. <br>Большое изображение будет уменьшено.',
     'app_primary_color' => 'Основной цвет приложения',
@@ -37,6 +37,11 @@ return [
     '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' => 'Адрес ссылки',
+    'app_footer_links_add' => 'Добавить ссылку',
     'app_disable_comments' => 'Отключение комментариев',
     'app_disable_comments_toggle' => 'Отключить комментарии',
     'app_disable_comments_desc' => 'Отключение комментариев на всех страницах. Существующие комментарии будут скрыты.',
@@ -68,7 +73,7 @@ return [
     'maint' => 'Обслуживание',
     'maint_image_cleanup' => 'Очистка изображений',
     'maint_image_cleanup_desc' => "Сканирует содержимое страниц и предыдущих версий и определяет изображения, которые не используются. Убедитесь, что у вас есть резервная копия базы данных и папки изображений перед запуском этой функции.",
-    'maint_image_cleanup_ignore_revisions' => 'Пропускать изображения в версиях',
+    'maint_delete_images_only_in_revisions' => 'Также удалять изображения, которые существуют только в старой версии страницы',
     'maint_image_cleanup_run' => 'Выполнить очистку',
     'maint_image_cleanup_warning' => 'Найдено :count возможно бесполезных изображений. Вы уверены, что хотите удалить эти изображения?',
     'maint_image_cleanup_success' => ':count возможно бесполезных изображений было найдено и удалено!',
@@ -80,10 +85,48 @@ return [
     '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_desc' => 'Здесь вы можете восстановить удаленные элементы или навсегда удалить их из системы. Этот список не отфильтрован в отличие от аналогичных списков действий в системе, где применяются фильтры.',
+    'recycle_bin_deleted_item' => 'Удаленный элемент',
+    'recycle_bin_deleted_parent' => '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' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'Удалено :count элементов из корзины.',
+    'recycle_bin_restore_notification' => 'Восстановлено :count элементов из корзины',
+
+    // Audit Log
+    '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' => 'Роли',
-    'role_user_roles' => 'Роли пользователя',
+    'role_user_roles' => 'Роли пользователей',
     'role_create' => 'Добавить роль',
     'role_create_success' => 'Роль успешно добавлена',
     'role_delete' => 'Удалить роль',
@@ -96,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' => 'Управление пользователями',
@@ -105,7 +149,9 @@ 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' => 'Эти разрешения контролируют доступ по умолчанию к параметрам внутри системы. Разрешения на книги, главы и страницы перезапишут эти разрешения.',
     'role_asset_admins' => 'Администраторы автоматически получают доступ ко всему контенту, но эти опции могут отображать или скрывать параметры пользовательского интерфейса.',
     'role_all' => 'Все',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Профиль пользователя',
     'users_add_new' => 'Добавить пользователя',
     'users_search' => 'Поиск пользователей',
+    'users_latest_activity' => 'Последние действия',
     'users_details' => 'Данные пользователя',
     'users_details_desc' => 'Укажите имя и адрес электронной почты для этого пользователя. Адрес электронной почты будет использоваться для входа в приложение.',
     'users_details_desc_no_email' => 'Задайте имя для этого пользователя, чтобы другие могли его узнать.',
@@ -136,9 +183,12 @@ return [
     'users_system_public' => 'Этот пользователь представляет любых гостевых пользователей, которые посещают ваше приложение. Он не может использоваться для входа в систему и назначается автоматически.',
     'users_delete' => 'Удалить пользователя',
     'users_delete_named' => 'Удалить пользователя :userName',
-    'users_delete_warning' => 'Это полностью удалит пользователя с именем \':userName\' из системы.',
+    'users_delete_warning' => 'Это полностью удалит пользователя \':userName\' из системы.',
     'users_delete_confirm' => 'Вы уверены что хотите удалить этого пользователя?',
-    'users_delete_success' => 'Пользователи успешно удалены',
+    'users_migrate_ownership' => 'Наследник контента',
+    'users_migrate_ownership_desc' => 'Выберите пользователя, если вы хотите, чтобы он стал владельцем всех элементов, в настоящее время принадлежащих удаляемому пользователю.',
+    'users_none_selected' => 'Пользователь не выбран',
+    'users_delete_success' => 'Пользователь успешно удален',
     'users_edit' => 'Редактировать пользователя',
     'users_edit_profile' => 'Редактировать профиль',
     'users_edit_success' => 'Пользователь успешно обновлен',
@@ -157,13 +207,17 @@ return [
     'users_api_tokens_create' => 'Создать токен',
     'users_api_tokens_expires' => 'Истекает',
     'users_api_tokens_docs' => 'Документация',
+    'users_mfa' => 'Двухфакторная аутентификация',
+    'users_mfa_desc' => 'Двухфакторная аутентификация повышает степень безопасности вашей учетной записи.',
+    'users_mfa_x_methods' => ':count method configured|:count methods configured',
+    'users_mfa_configure' => 'Настройка методов',
 
     // API Tokens
     'user_api_token_create' => 'Создать токен',
     'user_api_token_name' => 'Имя',
     'user_api_token_name_desc' => 'Присвойте вашему токену читаемое имя, в качестве напоминания о его назначении в будущем.',
     'user_api_token_expiry' => 'Истекает',
-    'user_api_token_expiry_desc' => 'Установите дату истечения срока действия этого токена. После этой даты запросы, сделанные с использованием этого токена, больше не будут работать. Если оставить это поле пустым, срок действия истечет через 100 лет.',
+    'user_api_token_expiry_desc' => 'Установите дату истечения срока действия этого токена. После наступления даты запросы, сделанные с использованием данного токена, больше не будут работать. Если оставить это поле пустым, срок действия истечет через 100 лет.',
     'user_api_token_create_secret_message' => 'Сразу после создания этого токена будут сгенерированы и отображены идентификатор токена и секретный ключ. Секретный ключ будет показан только один раз, поэтому перед продолжением обязательно скопируйте значение в безопасное и надежное место.',
     'user_api_token_create_success' => 'API токен успешно создан',
     'user_api_token_update_success' => 'API токен успешно обновлен',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index e5e9d1eec22cfdeba75674adedb76c5fab0cfc34..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute должен быть минимум :min символов.',
         'array'   => ':attribute должен содержать хотя бы :min элементов.',
     ],
-    'no_double_extension'  => ':attribute должен иметь только одно расширение файла.',
     'not_in'               => 'Выбранный :attribute некорректен.',
     'not_regex'            => 'Формат :attribute некорректен.',
     'numeric'              => ':attribute должен быть числом.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute обязательное поле когда :values не установлены.',
     'required_without_all' => ':attribute обязательное поле когда ни одно из :values не установлены.',
     'same'                 => ':attribute и :other должны совпадать.',
+    'safe_url'             => 'Предоставленная ссылка может быть небезопасной.',
     'size'                 => [
         'numeric' => ':attribute должен быть :size.',
         'file'    => ':attribute должен быть :size килобайт.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute должен быть строкой.',
     'timezone'             => ':attribute должен быть корректным часовым поясом.',
+    'totp'                 => 'Указанный код недействителен или истек.',
     'unique'               => ':attribute уже есть.',
     'url'                  => 'Формат :attribute некорректен.',
     'uploaded'             => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.',
index 444d78e95d45586755aecba945a578d8663bd084..9f9ded00e731a110a8913200dd4556efb9f36050 100644 (file)
@@ -6,43 +6,52 @@
 return [
 
     // Pages
-    'page_create'                 => 'vytvoril stránku',
+    '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 stránku',
+    'page_delete'                 => 'odstránil(a) stránku',
     'page_delete_notification'    => 'Stránka úspešne odstránená',
-    'page_restore'                => 'obnovil stránku',
+    'page_restore'                => 'obnovil(a) stránku',
     'page_restore_notification'   => 'Stránka úspešne obnovená',
-    'page_move'                   => 'presunul stránku',
+    'page_move'                   => 'presunul(a) stránku',
 
     // Chapters
-    'chapter_create'              => 'vytvoril kapitolu',
+    'chapter_create'              => 'vytvoril(a) kapitolu',
     'chapter_create_notification' => 'Kapitola úspešne vytvorená',
-    'chapter_update'              => 'aktualizoval kapitolu',
+    'chapter_update'              => 'aktualizoval(a) kapitolu',
     'chapter_update_notification' => 'Kapitola úspešne aktualizovaná',
-    'chapter_delete'              => 'odstránil kapitolu',
+    'chapter_delete'              => 'odstránil(a) kapitolu',
     'chapter_delete_notification' => 'Kapitola úspešne odstránená',
-    'chapter_move'                => 'presunul kapitolu',
+    'chapter_move'                => 'presunul(a) kapitolu',
 
     // Books
-    'book_create'                 => 'vytvoril knihu',
+    'book_create'                 => 'vytvoril(a) knihu',
     'book_create_notification'    => 'Kniha úspešne vytvorená',
-    'book_update'                 => 'aktualizoval knihu',
+    'book_update'                 => 'aktualizoval(a) knihu',
     'book_update_notification'    => 'Kniha úspešne aktualizovaná',
-    'book_delete'                 => 'odstránil knihu',
+    'book_delete'                 => 'odstránil(a) knihu',
     'book_delete_notification'    => 'Kniha úspešne odstránená',
-    'book_sort'                   => 'zoradil knihu',
+    'book_sort'                   => 'zoradil(a) knihu',
     'book_sort_notification'      => 'Kniha úspešne znovu zoradená',
 
     // 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'            => 'vytvoril(a) knižnicu',
+    'bookshelf_create_notification'    => 'Knižnica úspešne vytvorená',
+    'bookshelf_update'                 => 'aktualizoval(a) knižnicu',
+    'bookshelf_update_notification'    => 'Knižnica úspešne aktualizovaná',
+    '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ý',
 
     // Other
-    'commented_on'                => 'commented on',
+    'commented_on'                => 'komentoval(a)',
+    'permissions_update'          => 'aktualizované oprávnenia',
 ];
index 2b2e83a179dc76f0b0618ad08b9525ff036270a3..f79e79cca940747ceda6628f6801755ec9985fff 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'failed' => 'Tieto údaje nesedia s našimi záznamami.',
+    'failed' => 'Tieto údaje sa nezhodujú s našimi záznamami.',
     'throttle' => 'Priveľa pokusov o prihlásenie. Skúste znova o :seconds sekúnd.',
 
     // Login & Register
@@ -18,60 +18,95 @@ return [
 
     'name' => 'Meno',
     'username' => 'Používateľské meno',
-    'email' => 'Email',
+    'email' => 'E-mail',
     'password' => 'Heslo',
     'password_confirm' => 'Potvrdiť heslo',
     'password_hint' => 'Musí mať viac ako 7 znakov',
     'forgot_password' => 'Zabudli ste heslo?',
     'remember_me' => 'Zapamätať si ma',
-    'ldap_email_hint' => 'Zadajte prosím email, ktorý sa má použiť pre tento účet.',
+    'ldap_email_hint' => 'Zadajte prosím e-mail, ktorý sa má použiť pre tento účet.',
     'create_account' => 'Vytvoriť účet',
-    'already_have_account' => 'Already have an account?',
-    'dont_have_account' => 'Don\'t have an account?',
+    'already_have_account' => 'Už máte svoj ​​účet?',
+    'dont_have_account' => 'Nemáte účet?',
     'social_login' => 'Sociálne prihlásenie',
     'social_registration' => 'Sociálna registrácia',
-    'social_registration_text' => 'Registrovať sa a prihlásiť sa použitím inej služby.',
+    'social_registration_text' => 'Registrácia a prihlásenie pomocou inej služby.',
 
-    'register_thanks' => 'Ďakujeme zaregistráciu!',
-    'register_confirm' => 'Skontrolujte prosím svoj email a kliknite na potvrdzujúce tlačidlo pre prístup k :appName.',
+    'register_thanks' => 'Ďakujeme za registráciu!',
+    'register_confirm' => 'Prosím, skontrolujte svoj e-mail a kliknite na potvrdzujúce tlačidlo pre prístup k :appName.',
     'registrations_disabled' => 'Registrácie sú momentálne zablokované',
-    'registration_email_domain_invalid' => 'Táto emailová doména nemá prístup k tejto aplikácii',
+    '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' => 'Reset hesla',
-    'reset_password_send_instructions' => 'Zadajte svoj email nižšie a bude Vám odoslaný email s odkazom pre reset hesla.',
-    'reset_password_send_button' => 'Poslať odkaz na reset hesla',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password' => 'Resetovanie hesla',
+    'reset_password_send_instructions' => 'Nižšie zadajte svoj e-mail, na ktorý Vám zašleme odkaz pre resetovanie hesla.',
+    'reset_password_send_button' => 'Poslať odkaz na resetovanie hesla',
+    'reset_password_sent' => 'Odkaz na resetovanie hesla bude odoslaný na :email, ak sa táto e-mailová adresa nachádza v systéme.',
     'reset_password_success' => 'Vaše heslo bolo úspešne resetované.',
-    'email_reset_subject' => 'Reset Vášho :appName hesla',
-    'email_reset_text' => 'Tento email Ste dostali pretože sme dostali požiadavku na reset hesla pre Váš účet.',
-    'email_reset_not_requested' => 'Ak ste nepožiadali o reset hesla, nemusíte nič robiť.',
+    'email_reset_subject' => 'Resetovanie Vášho hesla do :appName',
+    '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ť email na :appName',
-    'email_confirm_greeting' => 'Ďakujeme za pridanie sa k :appName!',
-    'email_confirm_text' => 'Prosím potvrďte Vašu emailovú adresu kliknutím na tlačidlo nižšie:',
-    'email_confirm_action' => 'Potvrdiť email',
-    'email_confirm_send_error' => 'Je požadované overenie emailu, ale systém nemohol odoslať email. Kontaktujte administrátora by ste sa uistili, že email je nastavený správne.',
+    '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_resent' => 'Potvrdzujúci email bol poslaný znovu, skontrolujte prosím svoju emailovú schránku.',
+    'email_confirm_resent' => 'Potvrdzujúci e-mail bol poslaný znovu, skontrolujte prosím svoju e-mailovú schránku.',
 
-    'email_not_confirmed' => 'Emailová adresa nebola overená',
-    'email_not_confirmed_text' => 'Vaša emailová adresa nebola zatiaľ overená.',
-    'email_not_confirmed_click_link' => 'Prosím, kliknite na odkaz v emaili, ktorý bol poslaný krátko po Vašej registrácii.',
-    'email_not_confirmed_resend' => 'Ak nemôžete násť email, môžete znova odoslať overovací email odoslaním doleuvedeného formulára.',
-    'email_not_confirmed_resend_button' => 'Znova odoslať overovací email',
+    'email_not_confirmed' => 'E-mailová adresa nebola overená',
+    'email_not_confirmed_text' => 'Vaša e-mailová adresa nebola zatiaľ overená.',
+    'email_not_confirmed_click_link' => 'Prosím, kliknite na odkaz v e-maili, ktorý bol poslaný krátko po Vašej registrácii.',
+    'email_not_confirmed_resend' => 'Ak nemôžete nájsť e-mail, môžete znova odoslať overovací e-mail odoslaním doleuvedeného formulára.',
+    'email_not_confirmed_resend_button' => 'Znova odoslať overovací e-mail',
 
     // 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!'
+    'user_invite_email_subject' => 'Dostali ste pozvánku na pripojenie sa k aplikácii :appName!',
+    'user_invite_email_greeting' => 'Účet pre :appName bol pre vás vytvorený.',
+    'user_invite_email_text' => 'Kliknutím na tlačidlo nižšie nastavíte heslo k účtu a získate prístup:',
+    'user_invite_email_action' => 'Nastaviť heslo k účtu',
+    '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!',
+
+    // 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' => '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' => 'Mobilná aplikácia',
+    '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' => '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' => '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' => 'Sem vložte kód vygenerovaný vašou mobilnou aplikáciou',
+    'mfa_verify_access' => 'Overiť prí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' => 'Žiadny spôsob nebol nastavený',
+    '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' => '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' => 'Enter one of your remaining backup codes below:',
+    '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' => 'Multi-factor method configured, Please now login again using the configured method.',
 ];
\ No newline at end of file
index 5674fd5a72a56790b52a7fff998c2dda7ae24724..b9913db5942ec5a6b30c29c9f69d09a7487dd844 100644 (file)
@@ -11,8 +11,8 @@ return [
     'save' => 'Uložiť',
     'continue' => 'Pokračovať',
     'select' => 'Vybrať',
-    'toggle_all' => 'Toggle All',
-    'more' => 'More',
+    'toggle_all' => 'Prepnúť všetko',
+    'more' => 'Viac',
 
     // Form Labels
     'name' => 'Meno',
@@ -24,56 +24,72 @@ return [
     // Actions
     'actions' => 'Akcie',
     'view' => 'Zobraziť',
-    'view_all' => 'View All',
+    'view_all' => 'Zobraziť všetko',
     'create' => 'Vytvoriť',
     'update' => 'Aktualizovať',
     'edit' => 'Editovať',
     'sort' => 'Zoradiť',
     'move' => 'Presunúť',
-    'copy' => 'Copy',
-    'reply' => 'Reply',
+    'copy' => 'Kopírovať',
+    'reply' => 'Odpovedať',
     'delete' => 'Zmazať',
-    'search' => 'Hľadť',
+    'delete_confirm' => 'Potvrdiť zmazanie',
+    'search' => 'Hľadať',
     'search_clear' => 'Vyčistiť hľadanie',
-    'reset' => 'Reset',
+    'reset' => 'Resetovať',
     'remove' => 'Odstrániť',
-    'add' => 'Add',
-    'fullscreen' => 'Fullscreen',
+    '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äť',
 
     // Sort Options
-    'sort_options' => 'Sort Options',
-    'sort_direction_toggle' => 'Sort Direction Toggle',
-    'sort_ascending' => 'Sort Ascending',
-    'sort_descending' => 'Sort Descending',
-    'sort_name' => 'Name',
-    'sort_created_at' => 'Created Date',
-    'sort_updated_at' => 'Updated Date',
+    'sort_options' => 'Možnosti triedenia',
+    'sort_direction_toggle' => 'Zoradiť smerový prepínač',
+    'sort_ascending' => 'Zoradiť vzostupne',
+    'sort_descending' => 'Zoradiť zostupne',
+    'sort_name' => 'Meno',
+    'sort_default' => 'Východzie',
+    'sort_created_at' => 'Dátum vytvorenia',
+    'sort_updated_at' => 'Aktualizované dňa',
 
     // Misc
     'deleted_user' => 'Odstránený používateľ',
     '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' => 'Details',
-    'grid_view' => 'Grid View',
-    'list_view' => 'List View',
-    'default' => 'Default',
+    'details' => 'Podrobnosti',
+    'grid_view' => 'Zobrazenie v mriežke',
+    'list_view' => 'Zobraziť ako zoznam',
+    'default' => 'Predvolené',
     'breadcrumb' => 'Breadcrumb',
 
     // Header
-    'profile_menu' => 'Profile Menu',
+    'header_menu_expand' => 'Rozbaliť menu v záhlaví',
+    'profile_menu' => 'Menu profilu',
     'view_profile' => 'Zobraziť profil',
     'edit_profile' => 'Upraviť profil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Tmavý režim',
+    'light_mode' => 'Svetlý režim',
 
     // Layout tabs
-    'tab_info' => 'Info',
-    'tab_content' => 'Content',
+    'tab_info' => 'Informácie',
+    'tab_info_label' => 'Tab: Zobraziť vedľajšie informácie',
+    'tab_content' => 'Obsah',
+    '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:',
     'email_rights' => 'Všetky práva vyhradené',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Zásady ochrany osobných údajov',
+    'terms_of_service' => 'Podmienky používania',
 ];
index 726d8361438ec26712eaaf5611b8de49868a16b7..a6bae619c47be8ecc19bf88725909c61634d2985 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Načítať viac',
     'image_image_name' => 'Názov obrázka',
     'image_delete_used' => 'Tento obrázok je použitý na stránkach uvedených nižšie.',
-    'image_delete_confirm' => 'Kliknite znova na zmazať pre potvrdenie zmazania tohto obrázka.',
+    'image_delete_confirm_text' => 'Naozaj chcete vymazať tento obrázok?',
     'image_select_image' => 'Vybrať obrázok',
     'image_dropzone' => 'Presuňte obrázky sem alebo kliknite sem pre nahranie',
     'images_deleted' => 'Obrázky zmazané',
@@ -23,11 +23,12 @@ return [
     'image_upload_success' => 'Obrázok úspešne nahraný',
     'image_update_success' => 'Detaily obrázka úspešne aktualizované',
     'image_delete_success' => 'Obrázok úspešne zmazaný',
-    'image_upload_remove' => 'Remove',
+    'image_upload_remove' => 'Odstrániť',
 
     // Code Editor
-    'code_editor' => 'Edit Code',
-    'code_language' => 'Code Language',
-    'code_content' => 'Code Content',
-    'code_save' => 'Save Code',
+    'code_editor' => 'Upraviť kód',
+    'code_language' => 'Kód jazyka',
+    'code_content' => 'Obsah kódu',
+    'code_session_history' => 'História relácií',
+    'code_save' => 'Ulož kód',
 ];
index 2b42ca86cd43c89238b18d808ad1411c4807b8dd..0d430dcf61378273fff0c35a82cda7aaa33f943d 100644 (file)
@@ -11,103 +11,111 @@ return [
     'recently_updated_pages' => 'Nedávno aktualizované stránky',
     'recently_created_chapters' => 'Nedávno vytvorené kapitoly',
     'recently_created_books' => 'Nedávno vytvorené knihy',
-    'recently_created_shelves' => 'Recently Created Shelves',
+    'recently_created_shelves' => 'Nedávno vytvorené knižnice',
     'recently_update' => 'Nedávno aktualizované',
     'recently_viewed' => 'Nedávno zobrazené',
     'recent_activity' => 'Nedávna aktivita',
     'create_now' => 'Vytvoriť teraz',
     'revisions' => 'Revízie',
-    'meta_revision' => 'Revision #:revisionCount',
+    'meta_revision' => 'Upravené vydanie #:revisionCount',
     'meta_created' => 'Vytvorené :timeLength',
     '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' => '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é',
-    'export' => 'Export',
-    'export_html' => 'Contained Web File',
+    'export' => 'Exportovať',
+    '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' => 'Vlastník',
 
     // Search
     'search_results' => 'Výsledky hľadania',
-    'search_total_results_found' => ':count result found|:count total results found',
+    'search_total_results_found' => ':count výsledok found|:počet nájdených výsledkov',
     'search_clear' => 'Vyčistiť hľadanie',
     'search_no_pages' => 'Žiadne stránky nevyhovujú tomuto hľadaniu',
     'search_for_term' => 'Hľadať :term',
-    'search_more' => 'More Results',
-    'search_filters' => 'Search Filters',
-    '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_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_more' => 'Načítať ďalšie výsledky',
+    'search_advanced' => 'Rozšírené vyhľadávanie',
+    'search_terms' => 'Hľadané výrazy',
+    'search_content_type' => 'Typ obsahu',
+    'search_exact_matches' => 'Presná zhoda',
+    'search_tags' => 'Vyhľadávanie značiek',
+    'search_options' => 'Možnosti',
+    'search_viewed_by_me' => 'Videné mnou',
+    'search_not_viewed_by_me' => 'Nevidené mnou',
+    'search_permissions_set' => 'Oprávnenia',
+    'search_created_by_me' => 'Vytvorené mnou',
+    'search_updated_by_me' => 'Aktualizované mnou',
+    'search_owned_by_me' => 'Patriace mne',
+    'search_date_options' => 'Možnosti dátumu',
+    'search_updated_before' => 'Aktualizované pred',
+    'search_updated_after' => 'Aktualizované po',
+    'search_created_before' => 'Vytvorené pred',
+    'search_created_after' => 'Vytvorené po',
+    'search_set_date' => 'Nastaviť Dátum',
+    'search_update' => 'Aktualizujte vyhľadávanie',
 
     // 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',
+    'shelf' => 'Polica',
+    'shelves' => 'Police',
+    'x_shelves' => ':count Shelf|:count Police',
+    'shelves_long' => 'Poličky na knihy',
+    'shelves_empty' => 'Neboli vytvorené žiadne police',
+    'shelves_create' => 'Vytvoriť novú policu',
+    'shelves_popular' => 'Populárne police',
+    'shelves_new' => 'Nové police',
+    'shelves_new_action' => 'Nová polica',
+    'shelves_popular_empty' => 'Najpopulárnejšie police sa objavia tu.',
+    'shelves_new_empty' => 'Najpopulárnejšie police sa objavia tu.',
+    'shelves_save' => 'Uložiť policu',
+    'shelves_books' => 'Knihy na tejto polici',
+    'shelves_add_books' => 'Pridať knihy do tejto police',
+    'shelves_drag_books' => 'Potiahnite knihy sem a pridajte ich do tejto police',
+    'shelves_empty_contents' => 'Táto polica nemá priradené žiadne knihy',
+    'shelves_edit_and_assign' => 'Uprav policu a priraď knihy',
+    'shelves_edit_named' => 'Upraviť poličku::name',
+    'shelves_edit' => 'Upraviť policu',
+    'shelves_delete' => 'Odstrániť knižnicu',
+    'shelves_delete_named' => 'Odstrániť knižnicu :name',
+    'shelves_delete_explain' => "Týmto vymažete policu s názvom ': name'. Obsahované knihy sa neodstránia.",
+    'shelves_delete_confirmation' => 'Ste si istý, že chcete zmazať túto knižnicu?',
+    'shelves_permissions' => 'Oprávnenia knižnice',
+    'shelves_permissions_updated' => 'Oprávnenia knižnice aktualizované',
+    'shelves_permissions_active' => 'Oprávnenia knižnice aktívne',
+    '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' => 'Kopírovať oprávnenia pre knihy',
+    'shelves_copy_permissions' => 'Kopírovať oprávnenia',
     '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_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',
@@ -128,25 +136,24 @@ 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',
 
     // 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' => 'Toto zmaže kapitolu menom \':chapterName\', všetky stránky budú ostránené
-        a pridané priamo do rodičovskej knihy.',
+    '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',
@@ -158,7 +165,7 @@ return [
     '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',
@@ -177,7 +184,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',
@@ -195,24 +202,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' => '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',
@@ -234,21 +242,21 @@ 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',
     '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é.',
@@ -256,7 +264,7 @@ return [
     'attachments_upload' => 'Nahrať súbor',
     'attachments_link' => 'Priložiť odkaz',
     'attachments_set_link' => 'Nastaviť odkaz',
-    'attachments_delete_confirm' => 'Kliknite znova na zmazať pre potvrdenie zmazania prílohy.',
+    'attachments_delete' => 'Are you sure you want to delete this attachment?',
     '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.',
@@ -265,6 +273,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_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',
@@ -274,8 +283,8 @@ 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' => 'Šablóny',
+    'templates_set_as_template' => 'Táto stránka je šablóna',
     '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',
@@ -292,24 +301,24 @@ return [
     // 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_saving' => 'Ukladanie komentára...',
+    'comment_deleting' => 'Mazanie komentára...',
+    'comment_new' => 'Nový komentár',
+    'comment_created' => 'komentované :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_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',
 
     // Revision
     'revision_delete_confirm' => 'Naozaj chcete túto revíziu odstrániť?',
-    'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
+    '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.'
-];
\ No newline at end of file
+];
index 3b52a667c3675c29c8536612364c63e19701aa95..bb30243e8b13da41203b5731d4bc85e1bc3ceb15 100644 (file)
@@ -13,7 +13,7 @@ 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',
+    'email_confirmation_awaiting' => 'Potvrďte emailovú adresu pre užívateľský účet',
     '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',
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Nahrávanie súboru vypršalo.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
     'attachment_not_found' => 'Attachment not found',
 
     // Pages
@@ -84,6 +83,9 @@ return [
     '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.',
+    'image_not_found' => 'Obrázok nebol nájdený',
+    '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' => 'Vrátiť sa domov',
     'error_occurred' => 'Nastala chyba',
     'app_down' => ':appName je momentálne nedostupná',
index 30ed1b78220897b6f9ec530a163010cbd71dea2c..5b80f62da63877abbf0ebb07084bcfb6efd14aa7 100644 (file)
@@ -6,9 +6,9 @@
  */
 return [
 
-    'password' => 'Heslo musí obsahovať aspoň šesť znakov a musí byť rovnaké ako potvrdzujúce.',
+    'password' => 'Heslo musí obsahovať aspoň osem znakov a musí byť rovnaké ako potvrdzujúce.',
     'user' => "Nenašli sme používateľa s takou emailovou adresou.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Token na obnovenie hesla je pre túto e-mailovú adresu neplatný.',
     'sent' => 'Poslali sme Vám email s odkazom na reset hesla!',
     'reset' => 'Vaše heslo bolo resetované!',
 
index 75a847b0aaa58a252644539d5f25488c1ca75476..9ec036802a8a9f77911f249163ba3129ee7bcadc 100644 (file)
@@ -12,15 +12,15 @@ 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' => 'Verejný prístup',
     '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_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',
@@ -34,15 +34,20 @@ return [
     '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' => 'Domovská stránka aplikácie',
     'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_select' => 'Select a page',
+    'app_homepage_select' => 'Vybrať stránku',
+    'app_footer_links' => 'Odkazy v pätičke',
+    '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' => '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' => 'Farby obsahu',
     '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',
@@ -52,12 +57,12 @@ return [
 
     // Registration Settings
     'reg_settings' => 'Nastavenia registrácie',
-    'reg_enable' => 'Enable Registration',
-    'reg_enable_toggle' => 'Enable registration',
+    'reg_enable' => 'Povolenie registrácie',
+    'reg_enable_toggle' => 'Povoliť registrácie',
     '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' => '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' => 'Potvrdenie e-mailom',
     'reg_email_confirmation_toggle' => 'Require email confirmation',
     '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',
@@ -65,21 +70,59 @@ return [
     'reg_confirm_restrict_domain_placeholder' => 'Nie sú nastavené žiadne obmedzenia',
 
     // Maintenance settings
-    'maint' => 'Maintenance',
-    'maint_image_cleanup' => 'Cleanup Images',
+    'maint' => 'Údržba',
+    'maint_image_cleanup' => 'Prečistenie obrázkov',
     '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_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
+    'maint_image_cleanup_run' => 'Spustiť prečistenie',
     '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_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' => '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_run' => 'Odoslať testovací email',
     'maint_send_test_email_success' => 'Email sent to :address',
-    'maint_send_test_email_mail_subject' => 'Test Email',
+    'maint_send_test_email_mail_subject' => 'Testovací 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' => 'Otvoriť kôš',
+
+    // Recycle Bin
+    'recycle_bin' => 'Kôš',
+    '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' => 'Odstránené položky',
+    'recycle_bin_deleted_parent' => 'Parent',
+    '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_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.',
+
+    // 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' => 'Užívateľ',
+    'audit_table_event' => 'Udalosť',
+    'audit_table_related' => 'Related Item or Detail',
+    'audit_table_ip' => 'IP adresa',
+    'audit_table_date' => 'Dátum aktivity',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
 
     // Role Settings
     'roles' => 'Roly',
@@ -96,6 +139,7 @@ return [
     'role_details' => 'Detaily roly',
     'role_name' => 'Názov roly',
     'role_desc' => 'Krátky popis roly',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
     'role_external_auth_id' => 'External Authentication IDs',
     'role_system' => 'Systémové oprávnenia',
     'role_manage_users' => 'Spravovať používateľov',
@@ -105,7 +149,9 @@ return [
     'role_manage_page_templates' => 'Manage page templates',
     'role_access_api' => 'Access system API',
     'role_manage_settings' => 'Spravovať nastavenia aplikácie',
+    'role_export_content' => 'Export content',
     '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.',
     '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_all' => 'Všetko',
@@ -121,12 +167,13 @@ 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_details' => 'User Details',
+    'users_latest_activity' => 'Nedávna aktivita',
+    'users_details' => 'Údaje o používateľovi',
     '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' => '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' => 'Heslo používateľa',
     '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',
@@ -138,7 +185,10 @@ return [
     '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_delete_success' => 'Používateľ úspešne zmazaný',
+    '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' => 'Upraviť používateľa',
     'users_edit_profile' => 'Upraviť profil',
     'users_edit_success' => 'Používateľ úspešne upravený',
@@ -157,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',
@@ -164,7 +218,7 @@ return [
     '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_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',
@@ -172,8 +226,8 @@ return [
     '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_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?',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 8dc26905dc4b5ee8c606385cc40dd876a7ceb992..1f1a8841f382e19d7fb81711b6e42b0de1809172 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'         => 'The provided code is not valid or has already been used.',
     'before'               => ':attribute musí byť dátum pred :date.',
     'between'              => [
         'numeric' => ':attribute musí byť medzi :min a :max.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute musí mať aspoň :min znakov.',
         'array'   => ':attribute musí mať aspoň :min položiek.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
     'not_in'               => 'Vybraný :attribute je neplatný.',
     'not_regex'            => 'The :attribute format is invalid.',
     'numeric'              => ':attribute musí byť číslo.',
@@ -90,6 +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.',
     'size'                 => [
         'numeric' => ':attribute musí byť :size.',
         'file'    => ':attribute musí mať :size kilobajtov.',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute musí byť reťazec.',
     'timezone'             => ':attribute musí byť plantá časová zóna.',
+    'totp'                 => 'The provided code is not valid or has expired.',
     '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.',
index 01057f7415b17f7c940c8b98de9b82fcaa31c258..e1926c8ba78b91fd516df4b683a260a58c38c04f 100644 (file)
@@ -7,42 +7,51 @@ return [
 
     // Pages
     'page_create'                 => 'ustvarjena stran',
-    'page_create_notification'    => 'Zapis uspešno ustvarjen',
-    'page_update'                 => 'nadgrajena stran',
-    'page_update_notification'    => 'Uspešno posodobljeno',
+    'page_create_notification'    => 'Stran uspešno ustvarjena',
+    'page_update'                 => 'posodobljena stran',
+    'page_update_notification'    => 'Stran uspešno posodobljena',
     'page_delete'                 => 'izbrisana stran',
-    'page_delete_notification'    => 'Uspešno izbrisano',
+    'page_delete_notification'    => 'Stran uspešno izbrisana',
     'page_restore'                => 'obnovljena stran',
-    'page_restore_notification'   => 'Uspešna obnovitev',
+    'page_restore_notification'   => 'Stran uspešno obnovljena',
     'page_move'                   => 'premaknjena stran',
 
     // Chapters
     'chapter_create'              => 'ustvarjeno poglavje',
-    'chapter_create_notification' => 'Zapis uspešno ustvarjen',
-    'chapter_update'              => 'nadgradi poglavje',
-    'chapter_update_notification' => 'Uspešno posodobljeno',
+    'chapter_create_notification' => 'Poglavje uspešno ustvarjeno',
+    'chapter_update'              => 'posodobljeno poglavje',
+    'chapter_update_notification' => 'Poglavje uspešno posodobljeno',
     'chapter_delete'              => 'izbrisano poglavje',
-    'chapter_delete_notification' => 'Uspešno izbrisano',
+    'chapter_delete_notification' => 'Poglavje uspešno izbrisano',
     'chapter_move'                => 'premaknjeno poglavje',
 
     // Books
     'book_create'                 => 'knjiga ustvarjena',
-    'book_create_notification'    => 'Knjiga Uspešno Usvarjena',
+    'book_create_notification'    => 'Knjiga uspešno usvarjena',
     'book_update'                 => 'knjiga posodobljena',
-    'book_update_notification'    => 'Uspešno posodobljeno',
+    'book_update_notification'    => 'Knjiga uspešno posodobljena',
     'book_delete'                 => 'izbrisana knjiga',
-    'book_delete_notification'    => 'Uspešno izbrisano',
+    'book_delete_notification'    => 'Knjiga uspešno izbrisana',
     'book_sort'                   => 'razvrščena knjiga',
-    'book_sort_notification'      => 'Knjiga Uspešno Razvrščena',
+    'book_sort_notification'      => 'Knjiga uspešno razvrščena',
 
     // Bookshelves
     'bookshelf_create'            => 'knjižna polica izdelana',
-    'bookshelf_create_notification'    => 'Knjižna Polica Izdelana',
+    'bookshelf_create_notification'    => 'Knjižna polica uspešno ustvarjena',
     'bookshelf_update'                 => 'knjižna polica posodobljena',
-    'bookshelf_update_notification'    => 'Knjižna Polica Uspešno Posodobljena',
+    'bookshelf_update_notification'    => 'Knjižna polica uspešno posodobljena',
     'bookshelf_delete'                 => 'knjižna polica izbrisana',
-    'bookshelf_delete_notification'    => 'Knjižna Polica Uspešno Izbrisana',
+    'bookshelf_delete_notification'    => 'Knjižna polica uspešno Izbrisana',
+
+    // 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',
 
     // Other
     'commented_on'                => 'komentar na',
+    'permissions_update'          => 'pravice so posodobljene',
 ];
index 32aa9596e2b54a28bb1d0f725768df3250cf1d23..b6c41666ae2d8e53f53b72e1f11402b8a96d8235 100644 (file)
@@ -43,7 +43,7 @@ return [
     '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.',
     'reset_password_send_button' => 'Pošlji povezavo za ponastavitev',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'V kolikor e-poštni naslov :email obstaja v sistemu, bo nanj poslana povezava za ponastavitev gesla.',
     'reset_password_success' => 'Vaše geslo je bilo uspešno spremenjeno.',
     'email_reset_subject' => 'Ponastavi svoje :appName geslo',
     'email_reset_text' => 'To e-poštno sporočilo ste prejeli, ker smo prejeli zahtevo za ponastavitev gesla za vaš račun.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Geslo nastavljeno, sedaj imaš dostop do :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.',
 ];
\ No newline at end of file
index 9c1480d057205f8010d51031a60747b923780cb8..9f478e75dd8bf13ac264066da573876e581578be 100644 (file)
@@ -15,11 +15,11 @@ return [
     'more' => 'Več',
 
     // Form Labels
-    'name' => 'Ime',
+    'name' => 'Naziv',
     'description' => 'Opis',
     'role' => 'Vloga',
     'cover_image' => 'Naslovna slika',
-    'cover_image_description' => 'Slika naj bo okoli 440x250px velika.',
+    'cover_image_description' => 'Slika naj bo velika približno 440x250px.',
     
     // Actions
     'actions' => 'Dejanja',
@@ -33,12 +33,18 @@ return [
     'copy' => 'Kopiraj',
     'reply' => 'Odgovori',
     'delete' => 'Izbriši',
+    'delete_confirm' => 'Potrdi brisanje',
     'search' => 'Išči',
-    'search_clear' => 'Počisti iskanje',
+    'search_clear' => 'Razveljavi iskanje',
     'reset' => 'Ponastavi',
-    'remove' => 'Remove',
+    'remove' => 'Odstrani',
     'add' => 'Dodaj',
+    'configure' => 'Configure',
     'fullscreen' => 'Celozaslonski način',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => 'Možnosti razvrščanja',
@@ -46,14 +52,16 @@ return [
     'sort_ascending' => 'Razvrsti naraščajoče',
     'sort_descending' => 'Razvrsti padajoče',
     'sort_name' => 'Ime',
+    'sort_default' => 'Default',
     'sort_created_at' => 'Datum nastanka',
     'sort_updated_at' => 'Datum posodobitve',
 
     // Misc
     'deleted_user' => 'Izbrisan uporabnik',
     'no_activity' => 'Ni aktivnosti za prikaz',
-    'no_items' => 'Ni na voljo nobenih elementov',
+    '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,17 +71,25 @@ return [
     'breadcrumb' => 'Pot',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Meni profila',
     'view_profile' => 'Ogled profila',
     'edit_profile' => 'Uredi profil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Način temnega zaslona',
+    'light_mode' => 'Način svetlega zaslona',
 
     // Layout tabs
     'tab_info' => 'Informacije',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Vsebina',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
-    'email_action_help' => 'Če imate težave s klikom na ":actionText" gumb, kopirajte im prilepite spodnjo povezavo v vaš brskalnik:',
+    'email_action_help' => 'V kolikor imate težave s klikom na gumb ":actionText", kopirajte in prilepite spodnjo povezavo v vaš brskalnik:',
     'email_rights' => 'Vse pravice pridržane',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Pravilnik o zasebnosti',
+    'terms_of_service' => 'Pogoji uporabe',
 ];
index 42e87be318f2eec3937f8983287d683dd33d76e3..d1b454e6568b1531e3b34bdeb1c9d61769e5bb4d 100644 (file)
@@ -8,26 +8,27 @@ return [
     'image_select' => 'Izberi slike',
     'image_all' => 'Vse',
     'image_all_title' => 'Prikaži vse slike',
-    'image_book_title' => 'Preglej slike naložene v to knjigo',
+    'image_book_title' => 'Prikaži slike naložene v to knjigo',
     'image_page_title' => 'Preglej slike naložene na to stran',
     'image_search_hint' => 'Iskanje po nazivu slike',
     'image_uploaded' => 'Naloženo :uploadedDate',
-    'image_load_more' => 'Naloži več',
+    'image_load_more' => 'Dodatno naloži',
     'image_image_name' => 'Ime slike',
     'image_delete_used' => 'Ta slika je uporabljena na spodnjih straneh.',
-    'image_delete_confirm' => 'Ponovno kliknite izbriši, da potrdite izbris te slike.',
+    'image_delete_confirm_text' => 'Ste prepričani, da želite izbrisati to sliko?',
     'image_select_image' => 'Izberite sliko',
     'image_dropzone' => 'Povlecite slike ali kliknite tukaj za nalaganje',
     'images_deleted' => 'Slike so bile izbrisane',
     'image_preview' => 'Predogled slike',
     'image_upload_success' => 'Slika uspešno naložena',
     'image_update_success' => 'Podatki slike uspešno posodobljeni',
-    'image_delete_success' => 'Uspešno izbrisano',
+    'image_delete_success' => 'Slika uspešno izbrisana',
     'image_upload_remove' => 'Odstrani',
 
     // Code Editor
     'code_editor' => 'Uredi kodo',
     'code_language' => 'Koda jezika',
     'code_content' => 'Koda vsebine',
+    'code_session_history' => 'Zgodovina seje',
     'code_save' => 'Shrani kodo',
 ];
index ff1382159e38295bd0337803a975506a628e26f0..55f05e231b3ea837d736040a2b8280bbce317142 100644 (file)
@@ -8,37 +8,42 @@ return [
     // Shared
     'recently_created' => 'Nazadnje objavljeno',
     'recently_created_pages' => 'Nazadnje objavljene strani',
-    'recently_updated_pages' => 'Nedavno posodobljene strani',
+    'recently_updated_pages' => 'Nazadnje posodobljene strani',
     'recently_created_chapters' => 'Nazadnje objavljena poglavja',
     'recently_created_books' => 'Nazadnje objavljene knjige',
-    'recently_created_shelves' => 'Nazadnje objavljene police',
-    'recently_update' => 'Nedavno posodobljeno',
-    'recently_viewed' => 'Nedavno prikazano',
+    'recently_created_shelves' => 'Nazadnje ustvarjene police',
+    'recently_update' => 'Nazadnje posodobljeno',
+    'recently_viewed' => 'Nazadnje prikazano',
     'recent_activity' => 'Nedavna dejavnost',
     'create_now' => 'Ustvarite eno sedaj',
     'revisions' => 'Revizije',
-    'meta_revision' => 'Revizije #:revisionCount',
+    'meta_revision' => 'Številka revizije #:revisionCount',
     'meta_created' => 'Ustvarjeno :timeLength',
-    'meta_created_name' => 'Created :timeLength by :user',
+    'meta_created_name' => 'Ustvaril :timeLength uporabnik :user',
     'meta_updated' => 'Posodobljeno :timeLength',
-    'meta_updated_name' => 'Posodobljeno :timeLength by :user',
+    'meta_updated_name' => 'Posodobil :timeLength uporabnik :user',
+    'meta_owned_name' => 'V lasti :user',
     'entity_select' => 'Izbira entitete',
     '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',
     'export' => 'Izvozi',
     'export_html' => 'Vsebuje spletno datoteko',
-    'export_pdf' => 'Datoteka PDF',
+    'export_pdf' => 'PDF datoteka (.pdf)',
     'export_text' => 'Navadna besedilna datoteka',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'Dovoljenja',
-    'permissions_intro' => 'Ko so enkrat omogočena, bodo ta dovoljenja imela prednost pred dovoljenji za določanje vlog.',
+    'permissions_intro' => 'V trenutku, ko bodo omogočena, bodo imela ta dovoljenja prednost pred dovoljenji za določanje vlog.',
     'permissions_enable' => 'Omogoči dovoljenja po meri',
     'permissions_save' => 'Shrani dovoljenja',
+    'permissions_owner' => 'Lastnik',
 
     // Search
     'search_results' => 'Rezultati iskanja',
@@ -47,16 +52,18 @@ return [
     'search_no_pages' => 'Nobena stran se ne ujema z vašim iskanjem',
     'search_for_term' => 'Išči :term',
     'search_more' => 'Prikaži več rezultatov',
-    'search_filters' => 'Iskalni filtri',
+    'search_advanced' => 'Napredno iskanje',
+    'search_terms' => 'Iskalni izrazi',
     'search_content_type' => 'Vrsta vsebine',
     'search_exact_matches' => 'Natančno ujemanje',
-    'search_tags' => 'Iskanje oznak',
+    'search_tags' => 'Iskanje po oznakah',
     'search_options' => 'Možnosti',
     'search_viewed_by_me' => 'Ogledano',
     'search_not_viewed_by_me' => 'Neogledano',
     'search_permissions_set' => 'Nastavljena dovoljenja',
     'search_created_by_me' => 'Ustvaril sem jaz',
     'search_updated_by_me' => 'Posodobil sem jaz',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => 'Možnosti datuma',
     'search_updated_before' => 'Posodobljeno pred',
     'search_updated_after' => 'Posodobljeno po',
@@ -71,30 +78,31 @@ return [
     'x_shelves' => ':count Polica|:count Police',
     'shelves_long' => 'Knjižne police',
     'shelves_empty' => 'Ustvarjena ni bila nobena polica',
-    'shelves_create' => 'Izdelaj novo polico',
+    'shelves_create' => 'Ustvari novo polico',
     'shelves_popular' => 'Priljubljene police',
     'shelves_new' => 'Nove police',
     'shelves_new_action' => 'Nova polica',
     'shelves_popular_empty' => 'Najbolj priljubljene police se bodo pojavile tukaj.',
-    'shelves_new_empty' => 'Zadnje ustvarjene police se bodo pojavile tukaj.',
+    'shelves_new_empty' => 'Nazadnje ustvarjene police se bodo pojavile tukaj.',
     'shelves_save' => 'Shrani polico',
     'shelves_books' => 'Knjige na tej polici',
     'shelves_add_books' => 'Dodaj knjige na to polico',
-    'shelves_drag_books' => 'Povleci knjige sem za jih dodati na to polico',
-    'shelves_empty_contents' => 'Ta polica nima dodeljenih knjig',
-    'shelves_edit_and_assign' => 'Uredi polico za dodajanje knjig',
+    'shelves_drag_books' => 'Povlecite knjige sem, da jih dodate na to polico',
+    'shelves_empty_contents' => 'Na tej polici ni nobene knjige',
+    'shelves_edit_and_assign' => 'Uredi knjižno polico za dodajanje knjig',
     'shelves_edit_named' => 'Uredi knjižno polico :name',
     'shelves_edit' => 'Uredi knjižno polico',
     'shelves_delete' => 'Izbriši knjižno polico',
     'shelves_delete_named' => 'Izbriši knjižno polico :name',
-    'shelves_delete_explain' => "To bo izbrisalo knjižno polico z imenom ':name'. Vsebovane knjige ne bodo izbrisane.",
+    'shelves_delete_explain' => "S tem boste izbrisali knjižno polico z nazivom ':name'. Vsebovane knjige ne bodo izbrisane.",
     'shelves_delete_confirmation' => 'Ali ste prepričani, da želite izbrisati ta knjižno polico?',
     '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' => 'Kopiraj dovoljenja',
-    'shelves_copy_permissions_explain' => 'To bo uporabilo trenutne nastavitve dovoljenj te knjižne police za vse knjige, ki jih vsebuje. Pred aktiviranjem zagotovite, da so shranjene vse spremembe dovoljenj te knjižne police.',
+    '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.',
     'shelves_copy_permission_success' => 'Dovoljenja knjižne police kopirana na :count knjig',
 
     // Books
@@ -106,12 +114,12 @@ return [
     'books_recent' => 'Zadnje knjige',
     'books_new' => 'Nove knjige',
     'books_new_action' => 'Nova knjiga',
-    'books_popular_empty' => 'Najbolj priljubljene knjige se bodo pojavile tukaj.',
-    'books_new_empty' => 'Zadnje ustvarjene knjige se bodo pojavile tukaj.',
-    'books_create' => 'Izdelaj novo knjigo',
+    'books_popular_empty' => 'Tukaj bodo prikazane najbolj priljubljene knjige.',
+    'books_new_empty' => 'Tukaj bodo prikazane nazadnje ustvarjene knjige.',
+    'books_create' => 'Ustvari novo knjigo',
     'books_delete' => 'Izbriši knjigo',
     'books_delete_named' => 'Izbriši knjigo :bookName',
-    'books_delete_explain' => 'To bo izbrisalo knjigo z imenom \':bookName\'. Vse strani in poglavja bodo odstranjena.',
+    'books_delete_explain' => 'S tem boste izbrisali knjigo z nazivom \':bookName\'. Vse strani in poglavja bodo odstranjena.',
     'books_delete_confirmation' => 'Ali ste prepričani, da želite izbrisati to knjigo?',
     'books_edit' => 'Uredi knjigo',
     'books_edit_named' => 'Uredi knjigo :bookName',
@@ -119,14 +127,14 @@ return [
     'books_save' => 'Shrani knjigo',
     'books_permissions' => 'Dovoljenja knjige',
     'books_permissions_updated' => 'Posodobljena dovoljenja knjige',
-    'books_empty_contents' => 'Nobena stran ali poglavje ni bilo ustvarjeno za to knjigo.',
+    'books_empty_contents' => 'V tej knjigi ni bila ustvarjena še nobena stran ali poglavje.',
     'books_empty_create_page' => 'Ustvari novo stran',
     'books_empty_sort_current_book' => 'Razvrsti trenutno knjigo',
     'books_empty_add_chapter' => 'Dodaj poglavje',
     'books_permissions_active' => 'Aktivna dovoljenja knjige',
-    'books_search_this' => 'Išči to knjigo',
-    'books_navigation' => 'Navigacija knjige',
-    'books_sort' => 'Razvrsti vsebine knjige',
+    'books_search_this' => 'Išči v tej knjigi',
+    'books_navigation' => 'Navigacija po knjigi',
+    'books_sort' => 'Razvrsti vsebino knjige',
     'books_sort_named' => 'Razvrsti knjigo :bookName',
     'books_sort_name' => 'Razvrsti po imenu',
     'books_sort_created' => 'Razvrsti po datumu nastanka',
@@ -134,7 +142,7 @@ return [
     'books_sort_chapters_first' => 'Najprej poglavja',
     'books_sort_chapters_last' => 'Nazadnje poglavja',
     'books_sort_show_other' => 'Prikaži druge knjige',
-    'books_sort_save' => 'Shrani novo naročilo',
+    'books_sort_save' => 'Shrani novo razvrstitev',
 
     // Chapters
     'chapter' => 'Poglavje',
@@ -145,8 +153,8 @@ return [
     'chapters_create' => 'Ustvari novo poglavje',
     'chapters_delete' => 'Izbriši poglavje',
     'chapters_delete_named' => 'Izbriši poglavje :chapterName',
-    'chapters_delete_explain' => 'To bo izbrisalo poglavje z \':chapterName\'. Vse strani bodo odstranjene in dodane neposredno v matično knjigo.',
-    'chapters_delete_confirm' => 'Si prepričan, da želiš izbrisati to poglavje?',
+    'chapters_delete_explain' => 'Poglavje z imenom ":chapterName" bo izbrisano. Vse strani znotraj poglavja bodo prav tako izbrisane.',
+    'chapters_delete_confirm' => 'Ste prepričani, da želite izbrisati to poglavje?',
     'chapters_edit' => 'Uredi poglavje',
     'chapters_edit_named' => 'Uredi poglavje :chapterName',
     'chapters_save' => 'Shrani poglavje',
@@ -155,7 +163,7 @@ return [
     'chapter_move_success' => 'Poglavje premaknjeno v :bookName',
     'chapters_permissions' => 'Dovoljenja poglavij',
     'chapters_empty' => 'V tem poglavju trenutno ni strani.',
-    'chapters_permissions_active' => 'Aktivna dovoljenja poglavij',
+    'chapters_permissions_active' => 'Dovoljenja poglavij so aktivirana',
     'chapters_permissions_success' => 'Posodobljena dovoljenja poglavij',
     'chapters_search_this' => 'Išči v tem poglavju',
 
@@ -165,7 +173,7 @@ return [
     'x_pages' => ':count Stran|:count Strani',
     'pages_popular' => 'Priljubjene strani',
     'pages_new' => 'Nova stran',
-    'pages_attachments' => 'Priloge',
+    'pages_attachments' => 'Priponke',
     'pages_navigation' => 'Navigacija po strani',
     'pages_delete' => 'Izbriši stran',
     'pages_delete_named' => 'Izbriši stran :pageName',
@@ -184,16 +192,16 @@ return [
     'pages_edit_draft_save_at' => 'Osnutek shranjen ob ',
     'pages_edit_delete_draft' => 'Izbriši osnutek',
     'pages_edit_discard_draft' => 'Zavrzi osnutek',
-    'pages_edit_set_changelog' => 'Nastavi zgodovino sprememb',
+    'pages_edit_set_changelog' => 'Opiši spremembe na dokumentu',
     'pages_edit_enter_changelog_desc' => 'Vnesite kratek opis sprememb, ki ste jih naredili',
-    'pages_edit_enter_changelog' => 'Vnesite zgodovino sprememb',
+    'pages_edit_enter_changelog' => 'Vpišite vsebino sprememb',
     'pages_save' => 'Shrani stran',
     'pages_title' => 'Naslov strani',
     'pages_name' => 'Ime strani',
     'pages_md_editor' => 'Urejevalnik',
     'pages_md_preview' => 'Predogled',
     'pages_md_insert_image' => 'Vstavi sliko',
-    'pages_md_insert_link' => 'Vnesi povezavo entitete',
+    'pages_md_insert_link' => 'Vnesi povezavo do objekta',
     'pages_md_insert_drawing' => 'Vstavi risbo',
     'pages_not_in_chapter' => 'Stran ni v poglavju',
     'pages_move' => 'Premakni stran',
@@ -207,28 +215,29 @@ return [
     'pages_revisions' => 'Pregled strani',
     'pages_revisions_named' => 'Pregledi strani za :pageName',
     'pages_revision_named' => 'Pregled strani za :pageName',
+    'pages_revision_restored_from' => 'Obnovljeno iz #:id; :summary',
     'pages_revisions_created_by' => 'Ustvaril',
     'pages_revisions_date' => 'Datum revizije',
     'pages_revisions_number' => '#',
-    'pages_revisions_numbered' => 'Revizija #:id',
-    'pages_revisions_numbered_changes' => 'Revizija #:id Changes',
+    'pages_revisions_numbered' => 'Revizija št. :id',
+    'pages_revisions_numbered_changes' => 'Revizija št. #:id Changes',
     'pages_revisions_changelog' => 'Dnevnik sprememb',
     'pages_revisions_changes' => 'Spremembe',
     'pages_revisions_current' => 'Trenutna različica',
     'pages_revisions_preview' => 'Predogled',
     'pages_revisions_restore' => 'Obnovi',
-    'pages_revisions_none' => 'Ta stran nima revizije',
+    'pages_revisions_none' => 'Ta stran nima popravkov',
     'pages_copy_link' => 'Kopiraj povezavo',
     'pages_edit_content_link' => 'Uredi vsebino',
     'pages_permissions_active' => 'Aktivna dovoljenja strani',
     'pages_initial_revision' => 'Prvotno objavljeno',
     'pages_initial_name' => 'Nova stran',
-    'pages_editing_draft_notification' => 'Trenutno urejate osnutek ku je bil nazadnje shranjen :timeDiff.',
-    'pages_draft_edited_notification' => 'Ta stran je od takrat posodobljena. Priporočamo, da zavržete ta osnutek.',
+    '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_edit_active' => [
         'start_a' => ':count uporabnikov je začelo urejati to stran',
         'start_b' => ':userName je začel urejati to stran',
-        'time_a' => 'od kar je bila stran nazandnje posodobljena',
+        'time_a' => 'odkar je bila stran nazandnje posodobljena',
         'time_b' => 'v zadnjih :minCount minutah',
         'message' => ':start :time. Pazite, da ne boste prepisali posodobitev drug drugega!',
     ],
@@ -245,47 +254,48 @@ return [
     'tags' =>  'Oznake',
     'tag_name' =>  'Ime oznake',
     'tag_value' => 'Vrednost oznake (opcijsko)',
-    'tags_explain' => "Dodajte nekaj oznak za boljšo kategorizacijo vaše vsebine.\nDodelite lahko vrednost oznake za boljšo poglobljeno organizacijo.",
+    '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',
-    'attachments' => 'Priloge',
-    'attachments_explain' => 'Naložite nekaj datotek ali pripnite nekaj povezav, da bo prikazano na vaši strani. Te so vidne v stranski vrstici strani.',
+    '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.',
-    'attachments_items' => 'Priloženi element',
+    'attachments_items' => 'Priloženi elementi',
     'attachments_upload' => 'Naloži datoteko',
     'attachments_link' => 'Pripni povezavo',
     'attachments_set_link' => 'Nastavi povezavo',
-    'attachments_delete_confirm' => 'Ponovno kliknite izbriši, da potrdite izbris te priloge.',
+    'attachments_delete' => 'Ali ste prepričani, da želite izbrisati to priponko?',
     'attachments_dropzone' => 'Spustite datoteke ali kliknite tukaj, če želite priložiti datoteko',
     'attachments_no_files' => 'Nobena datoteka ni bila naložena',
     'attachments_explain_link' => 'Lahko pripnete povezavo, če ne želite naložiti datoteke. Lahko je povezava na drugo stran ali povezava do dateteke v oblaku.',
     'attachments_link_name' => 'Ime povezave',
     'attachment_link' => 'Povezava priponke',
     'attachments_link_url' => 'Povezava do datoteke',
-    'attachments_link_url_hint' => 'Url mesta ali datoteke',
+    'attachments_link_url_hint' => 'Url spletnega mesta ali datoteke',
     'attach' => 'Pripni',
+    'attachments_insert_link' => 'Dodaj povezavo na priponko na stran',
     'attachments_edit_file' => 'Uredi datoteko',
     'attachments_edit_file_name' => 'Ime datoteke',
     'attachments_edit_drop_upload' => 'Spustite datoteke ali kliknite tukaj, če želite naložiti in prepisati',
-    'attachments_order_updated' => 'Priloga posodobljena',
+    'attachments_order_updated' => 'Razvrščanje priponk posodobljeno',
     'attachments_updated_success' => 'Podrobnosti priloge posodobljene',
-    'attachments_deleted' => 'Priloga izbirsana',
+    'attachments_deleted' => 'Priponka izbirsana',
     'attachments_file_uploaded' => 'Datoteka uspešno naložena',
     'attachments_file_updated' => 'Datoteka uspešno posodobljena',
     'attachments_link_attached' => 'Povezava uspešno dodana na stran',
     'templates' => 'Predloge',
     'templates_set_as_template' => 'Stran je predloga',
-    'templates_explain_set_as_template' => 'To stran lahko nastavite kot predlogo tako bo njena vsebina uporabljena pri izdelavi drugih strani. Ostali uporabniki bodo lahko uporabljali to predlogo, če imajo dovoljenja za to stran.',
+    'templates_explain_set_as_template' => 'To stran lahko nastavite kot predlogo in njeno vsebino uporabite pri izdelavi drugih strani. Ostali uporabniki bodo lahko uporabljali to predlogo, če imajo dovoljenja za to stran.',
     'templates_replace_content' => 'Zamenjaj vsebino strani',
-    'templates_append_content' => 'Dodajte vsebini strani',
-    'templates_prepend_content' => 'Dodaj k vsebini strani',
+    'templates_append_content' => 'Dodajte vsebini strani',
+    'templates_prepend_content' => 'Dodaj predpono k vsebini strani',
 
     // Profile View
     'profile_user_for_x' => 'Uporabnik že :time',
     'profile_created_content' => 'Ustvarjena vsebina',
     'profile_not_created_pages' => ':userName ni izdelal nobene strani',
     'profile_not_created_chapters' => ':userName ni izdelal nobenega poglavja',
-    'profile_not_created_books' => ':userName ni izdelal nobene knjige',
+    'profile_not_created_books' => ':userName ni objavil nobene knjige',
     'profile_not_created_shelves' => ':userName ni izdelal nobene knjižne police',
 
     // Comments
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index bf1564eefd9228c6d7d615ef614cff1907049d6e..b0e253be0e8c28f68a25ec45cb00199ddba61090 100644 (file)
@@ -12,16 +12,16 @@ return [
     'error_user_exists_different_creds' => 'Uporabnik z e-pošto :email že obstaja, vendar z drugačnimi poverilnicami.',
     'email_already_confirmed' => 'E-naslov je že bil potrjen, poskusite se prijaviti.',
     'email_confirmation_invalid' => 'Ta potrditveni žeton ni veljaven ali je že bil uporabljen. Poizkusite znova.',
-    'email_confirmation_expired' => 'Potrditveni žeton je pretečen. Nova potrditvena e-pošta je bila poslana.',
+    'email_confirmation_expired' => 'Potrditveni žeton je potekel. Nova potrditvena e-pošta je bila poslana.',
     'email_confirmation_awaiting' => 'Potrebno je potrditi e-naslov',
     'ldap_fail_anonymous' => 'Dostop do LDAP ni uspel z anonimno povezavo',
     'ldap_fail_authed' => 'Neuspešen LDAP dostop z danimi podrobnostimi dn & gesla',
-    'ldap_extension_not_installed' => 'PHP razširitev za LDAP ni nameščen',
+    'ldap_extension_not_installed' => 'PHP razširitev za LDAP ni nameščena',
     'ldap_cannot_connect' => 'Ne morem se povezati na LDAP strežnik, neuspešna začetna povezava',
     'saml_already_logged_in' => 'Že prijavljen',
     'saml_user_not_registered' => 'Uporabniško ime :name ni registrirano in avtomatska registracija je onemogočena',
     '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 povzročil te težave.',
+    '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',
     'social_no_action_defined' => 'Akcija ni določena',
     'social_login_bad_response' => "Napaka pri :socialAccount prijavi:\n:error",
@@ -33,28 +33,27 @@ return [
     'social_account_register_instructions' => 'Če še nimate računa, se lahko registrirate z uporabo :socialAccount.',
     'social_driver_not_found' => 'Socialni vtičnik ni najden',
     'social_driver_not_configured' => 'Vaše nastavitve :socialAccount niso pravilo nastavljene.',
-    'invite_token_expired' => 'Ta link je pretečen. Namesto tega lahko ponastavite vaše geslo računa.',
+    'invite_token_expired' => 'Ta povezava je potekla. Namesto tega lahko ponastavite vaše geslo računa.',
 
     // System
-    'path_not_writable' => 'Poti :filePath ni bilo mogoče naložiti. Prepričajte se da je zapisljiva na strežnik.',
+    'path_not_writable' => 'Poti :filePath ni bilo mogoče naložiti. Prepričajte se, da je zapisljiva na strežnik.',
     'cannot_get_image_from_url' => 'Ne morem pridobiti slike z :url',
     'cannot_create_thumbs' => 'Strežnik ne more izdelati sličice. Prosimo preverite če imate GD PHP razširitev nameščeno.',
-    'server_upload_limit' => 'Strežnik ne dovoli nalaganj take velikosti. Prosimo poskusite manjšo velikost datoteke.',
-    'uploaded'  => 'Strežnik ne dovoli nalaganj take velikosti. Prosimo poskusite manjšo velikost datoteke.',
+    'server_upload_limit' => 'Strežnik ne dovoli nalaganj take velikosti. Prosimo poskusite z manjšo velikostjo datoteke.',
+    'uploaded'  => 'Strežnik ne dovoli nalaganj take velikosti. Prosimo poskusite zmanjšati velikost datoteke.',
     'image_upload_error' => 'Prišlo je do napake med nalaganjem slike',
     'image_upload_type_error' => 'Napačen tip (format) slike',
     'file_upload_timeout' => 'Čas nalaganjanja datoteke je potekel.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Neskladje strani med posodobitvijo priloge',
     'attachment_not_found' => 'Priloga ni najdena',
 
     // Pages
     'page_draft_autosave_fail' => 'Osnutka ni bilo mogoče shraniti. Pred shranjevanjem te strani se prepričajte, da imate internetno povezavo',
-    'page_custom_home_deletion' => 'Ne morem izbrisati strani dokler je nastavljena kot domača stran',
+    'page_custom_home_deletion' => 'Ne morem izbrisati strani, ki je nastavljena kot domača stran',
 
     // Entities
-    'entity_not_found' => 'Entiteta ni najdena',
+    'entity_not_found' => 'Ne najdem tega objekta',
     'bookshelf_not_found' => 'Knjižna polica ni najdena',
     'book_not_found' => 'Knjiga ni najdena',
     'page_not_found' => 'Stran ni najdena',
@@ -70,20 +69,23 @@ return [
     // Roles
     'role_cannot_be_edited' => 'Te vloge mi možno urejati',
     'role_system_cannot_be_deleted' => 'Ta vloga je sistemska in je ni možno brisati',
-    'role_registration_default_cannot_delete' => 'Te vloge ni možno brisati dokler je nastavljena kot privzeta',
-    'role_cannot_remove_only_admin' => 'Ta uporabnik je edini administrator. Dodelite vlogo administratorja drugemu uporabniku preden ga poskusite brisati.',
+    'role_registration_default_cannot_delete' => 'Te vloge ni možno brisati, dokler je nastavljena kot privzeta',
+    'role_cannot_remove_only_admin' => 'Ta uporabnik je edini administrator. Dodelite vlogo administratorja drugemu uporabniku, preden ga poskusite brisati.',
 
     // Comments
     'comment_list' => 'Napaka se je pojavila pri pridobivanju komentarjev.',
-    'cannot_add_comment_to_draft' => 'Ni mogoče dodajanje komentarjev v osnutek.',
-    'comment_add' => 'Napaka se je pojavila pri dodajanju / posodobitev komentarjev.',
+    'cannot_add_comment_to_draft' => 'V osnutek ni možno dodajati komentarjev.',
+    'comment_add' => 'Napaka se je pojavila pri dodajanju / posodobitvi komentarjev.',
     'comment_delete' => 'Napaka se je pojavila pri brisanju komentarja.',
     'empty_comment' => 'Praznega komentarja ne morete objaviti.',
 
     // Error pages
     '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, da jo vidite.',
+    '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 b7df08291306afcc822f99aadbd9cc5d66259b32..e04a98d89b5283f606d1575e9783918661f6d753 100644 (file)
@@ -6,7 +6,7 @@
  */
 return [
 
-    'previous' => '&laquo; Prejšnje',
+    'previous' => '&laquo; Predhodno',
     'next'     => 'Naslednje &raquo;',
 
 ];
index 774cf4cbf48c1a0858bd67ab697de030c31f2651..e9e195fe98ee7a1345b6abcb09edee0abbe667e5 100644 (file)
@@ -6,9 +6,9 @@
  */
 return [
 
-    'password' => 'Gesla morajo biti najmanj osem znakov in se morajo ujemati s potrditvijo.',
+    'password' => 'Gesla morajo biti najmanj osem znakov dolga in se morajo ujemati s potrditvijo.',
     'user' => "Ne moremo najti uporabnika s tem e-poštnim naslovom.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Žeton za ponastavitev gesla ni veljaven za ta e-poštni naslov.',
     'sent' => 'Poslali smo vam povezavo za ponastavitev gesla!',
     'reset' => 'Vaše geslo je bilo ponastavljeno!',
 
index 24cd9229a5a0a966c7704c7ada969d3cec87fe72..cadba7bce937bd7c041e33607ac5484330a38c27 100644 (file)
@@ -9,10 +9,10 @@ return [
     // Common Messages
     'settings' => 'Nastavitve',
     'settings_save' => 'Shrani nastavitve',
-    'settings_save_success' => 'Nastavitve, shranjene',
+    'settings_save_success' => 'Nastavitve shranjene',
 
     // App Settings
-    'app_customization' => 'Prilagajanje',
+    'app_customization' => 'Prilagoditev',
     'app_features_security' => 'Lastnosti & Varnost',
     'app_name' => 'Ime aplikacije',
     'app_name_desc' => 'To ime je prikazano v glavi in vsaki sistemski e-pošti.',
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Domača stran aplikacije',
     'app_homepage_desc' => 'Izberi pogled, da se pokaže na domači strani, namesto osnovnega pogleda. Dovoljenja strani so prezrta za izbrane strani.',
     'app_homepage_select' => 'Izberi stran',
+    'app_footer_links' => 'Povezave v nogi',
+    'app_footer_links_desc' => 'Dodaj URL povezave, ki bodo na voljo v nogi spletne strani. Povezave bodo vidne na dnu večine strani, vključno s tistimi, ki ne zahtevajo prijave. Na voljo imate oznako "trans::<key>" za uporabo sistemskih prevodov. Na primer: uporaba oznake "trans::common.privacy_policy" bo poskrbela za prevod besedila "Privacy Policy" in oznaka "trans::common.terms_of_service" bo poskrbela za prevod besedila "Terms of Service".',
+    'app_footer_links_label' => 'Oznaka povezave',
+    'app_footer_links_url' => 'Naslov URL povezave',
+    'app_footer_links_add' => 'Dodaj povezavo v nogo',
     'app_disable_comments' => 'Onemogoči komentarje',
     'app_disable_comments_toggle' => 'Onemogoči komentarje',
     'app_disable_comments_desc' => 'Onemogoči komentarje na vseh straneh v aplikaciji. <br> Obstoječi komentarji se ne prikazujejo.',
@@ -66,116 +71,162 @@ return [
 
     // Maintenance settings
     'maint' => 'Vzdrževanje',
-    'maint_image_cleanup' => 'odstrani /počisti slike',
-    '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' => 'zaženite čiščenje',
-    'maint_image_cleanup_warning' => ':zaznano je bilo število neuporabljenih slik. Ali si prepričan, da želiš odstraniti izbrane slike?',
-    'maint_image_cleanup_success' => ':najdeno in izbrisano je bilo število neuporabljenih slik!',
-    'maint_image_cleanup_nothing_found' => 'Najdenih ni bilo nobenih neuporabljenih slik!',
+    '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_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?',
+    'maint_image_cleanup_success' => ':count verjetno neuporavljenih slik je bilo najdenih in izbrisanih!',
+    'maint_image_cleanup_nothing_found' => 'Ni bilo najdenih neuporabljenih slik, nič ni izbrisano!',
     'maint_send_test_email' => 'Pošlji testno e-pismo',
     'maint_send_test_email_desc' => 'To pošlje testno e-pošto na vaš e-poštni naslov, naveden v vašem profilu.',
-    'maint_send_test_email_run' => 'Pošlji preizkusno sporočilo',
+    'maint_send_test_email_run' => 'Pošlji testno sporočilo',
     'maint_send_test_email_success' => 'e-pošta poslana na :naslov',
-    'maint_send_test_email_mail_subject' => 'Preizkusno sporočilo',
+    'maint_send_test_email_mail_subject' => 'Testno e-sporočilo',
     'maint_send_test_email_mail_greeting' => 'Zdi se, da dostava e-pošte deluje!',
-    'maint_send_test_email_mail_text' => 'Čestitke! Če ste prejeli e.poštno obvestilo so bile vaše e-poštne nastavitve pravilno konfigurirane.',
+    'maint_send_test_email_mail_text' => 'Čestitke! Če ste prejeli e-poštno obvestilo so bile vaše e-poštne nastavitve pravilno konfigurirane.',
+    'maint_recycle_bin_desc' => 'Izbrisane police, knjige, poglavja in strani se pošljejo v koš, da jih je mogoče obnoviti ali trajno izbrisati. Starejše predmete v košu lahko čez nekaj časa samodejno odstranite, odvisno od konfiguracije sistema.',
+    'maint_recycle_bin_open' => 'Odpri koš',
+
+    // Recycle Bin
+    '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?',
+    'recycle_bin_restore' => 'Obnovi',
+    'recycle_bin_contents_empty' => 'Koš je prazen',
+    'recycle_bin_empty' => 'Izprazni koš',
+    'recycle_bin_empty_confirm' => 'S tem boste trajno uničili vse predmete v košu, vključno z vsebino vsakega predmeta. Ali ste prepričani, da želite izprazniti koš?',
+    'recycle_bin_destroy_confirm' => 'S tem dejanjem boste ta element skupaj s spodaj navedenimi podrejenimi elementi trajno izbrisali iz sistema in te vsebine ne boste mogli obnoviti. Ali ste prepričani, da želite trajno izbrisati ta element?',
+    'recycle_bin_destroy_list' => 'Predmeti, ki naj bodo trajno izbrisani',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Dnevnik dogodkov',
+    'audit_desc' => 'Ta dnevnik dogodkov prikazuje seznam dejavnosti, ki jim sledi sistem. Seznam je nefiltriran, za razliko od podobnih seznamov dejavnosti v sistemu, kjer se uporabljajo filtri dovoljenj.',
+    'audit_event_filter' => 'Filter dogodkov',
+    'audit_event_filter_no_filter' => 'Ni filtra',
+    'audit_deleted_item' => 'Izbrisan element',
+    'audit_deleted_item_name' => 'Naziv: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Vloge',
-    'role_user_roles' => 'Pravilo uporabnika',
-    'role_create' => 'Izdelaj novo polico',
-    'role_create_success' => 'Zapis uspešno ustvarjen',
+    'role_user_roles' => 'Vloge uporabnika',
+    'role_create' => 'Ustvari novo vlogo',
+    'role_create_success' => 'Vloga uspešno ustvarjena',
     'role_delete' => 'Brisanje vloge',
-    'role_delete_confirm' => 'Izbrisana bo vloga z imenom \':vlogaImena\'.',
-    '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' => "Uporabniki niso prenosljivi",
-    'role_delete_sure' => 'Ali ste prepričani, da želite izbrisati to element?',
-    'role_delete_success' => 'Uspešno izbrisano',
-    'role_edit' => 'Uredi zapis',
-    'role_details' => 'Podrobnosti zapisa',
-    'role_name' => 'Ime zapisa',
-    'role_desc' => 'Kratki opis ',
+    'role_delete_confirm' => 'Izbrisana bo vloga z imenom \':roleName\'.',
+    'role_delete_users_assigned' => 'Ta vloga ima dodeljenih :userCount uporabnikov. V kolikor želite uporabnike preseliti iz te vloge, spodaj izberite novo vlogo.',
+    'role_delete_no_migration' => "Ne prenašaj uporabnikov",
+    'role_delete_sure' => 'Ali ste prepričani, da želite izbrisati to vlogo?',
+    'role_delete_success' => 'Vloga uspešno izbrisana',
+    'role_edit' => 'Uredi vlogo',
+    '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',
-    'role_manage_roles' => 'Vloga upravljanja & vloga dovoljenj',
-    'role_manage_entity_permissions' => 'Upravljanje vseh knjig, poglavij & dovoljenj',
-    '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_roles' => 'Upravljanje vlog in dovoljenja vlog',
+    'role_manage_entity_permissions' => 'Upravljanje dovoljenj vseh knjig, poglavij in strani',
+    'role_manage_own_entity_permissions' => 'Upravljanje dovoljenj za svojo knjigo, poglavje in strani',
+    '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',
-    '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.',
+    'roles_system_warning' => 'Zavedajte se, da lahko dostop do kateregakoli od zgornjih treh dovoljenj uporabniku omogoči, da spremeni lastne privilegije ali privilegije drugih v sistemu. Vloge s temi dovoljenji dodelite samo zaupanja vrednim uporabnikom.',
+    'role_asset_desc' => 'Ta dovoljenja nadzorujejo privzeti dostop do sredstev v sistemu. Dovoljenja za knjige, poglavja in strani bodo razveljavila ta dovoljenja.',
+    'role_asset_admins' => 'Skrbniki samodejno pridobijo dostop do vseh vsebin, vendar lahko te možnosti prikažejo ali pa skrijejo možnosti uporabniškega vmesnika.',
     'role_all' => 'Vse',
-    'role_own' => 'Own',
-    'role_controlled_by_asset' => '
-46/5000
-Nadzira ga sredstvo, v katerega so naloženi',
+    'role_own' => 'Lasten',
+    'role_controlled_by_asset' => 'Nadzira ga sredstvo, v katerega so naloženi',
     'role_save' => 'Shrani vlogo',
     'role_update_success' => 'Vloga uspešno posodobljena',
     'role_users' => 'Uporabniki v tej vlogi',
-    'role_users_none' => '
-V tej vlogi trenutno ni dodeljen noben uporabnik',
+    'role_users_none' => 'Tej vlogi trenutno ni dodeljen noben uporabnik',
 
     // Users
     'users' => 'Uporabniki',
     'user_profile' => 'Uporabniški profil',
     'users_add_new' => 'Dodaj novega uporabnika',
     'users_search' => 'Išči uporabnike',
+    'users_latest_activity' => 'Zadnja dejavnost',
     'users_details' => 'Podatki o uporabniku',
     'users_details_desc' => 'Nastavite prikazno ime in e-poštni naslov za tega uporabnika. E-poštni naslov bo uporabljen za prijavo v aplikacijo.',
     'users_details_desc_no_email' => ' Nastavite prikazno ime za tega uporabnika, da ga bodo drugi lahko prepoznali.',
     'users_role' => 'Vloge uporabnika',
-    'users_role_desc' => 'Izberi katere vloge bodo dodeljene uporabniku. Če je uporabniku dodeljenih več vlog, se dovoljenja združijo in prejmenjo vse sposobnosti dodeljenih vlog.',
+    '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. Dolg mora biti vsaj 6 znakov.',
-    'users_send_invite_text' => 'Uporabniku lahko pošljete e-poštno sporočilo s povabilom, ki mu omogoča, da nastavi svoje geslo, ali ga nastavite kar sami.',
-    'users_send_invite_option' => 'Pošlji uporabniku e-povabilo',
+    'users_password_desc' => 'Nastavite geslo, ki se uporablja za prijavo v aplikacijo. Dolgo mora biti vsaj 6 znakov.',
+    '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',
     'users_external_auth_id_desc' => 'To je ID, s katerim se ta uporabnik ujema pri komunikaciji z vašim zunanjim sistemom za preverjanje pristnosti.',
     'users_password_warning' => 'Spodaj izpolni le, če želiš spremeniti geslo.',
-    'users_system_public' => 'Ta uporabnik predstavlja vse gostujoče uporabnike, ki obiščejo vaš primer. Za prijavo je ni mogoče uporabiti, ampak je dodeljena samodejno.',
+    'users_system_public' => 'Ta uporabnik predstavlja vse gostujoče uporabnike, ki obiščejo vašo wiki stran. Za prijavo je ni mogoče uporabiti, ampak je dodeljena samodejno.',
     'users_delete' => 'Brisanje uporabnika',
     'users_delete_named' => 'Brisanje uporabnika :userName',
     'users_delete_warning' => 'Iz sistema se bo popolnoma  izbrisal uporabnik z imenom \':userName\'',
-    'users_delete_confirm' => 'Ste prepričani, da želite izbrisati izbranega uporabnika?',
-    'users_delete_success' => 'Uporabniki uspešno odstranjeni.',
+    'users_delete_confirm' => 'Ste prepričani, da želite izbrisati tega uporabnika?',
+    'users_migrate_ownership' => 'Prenesi lastništvo',
+    'users_migrate_ownership_desc' => 'Izberite uporabnika, če želite nanj prenesti lastništvo vseh vnosov.',
+    'users_none_selected' => 'Ni izbranega uporabnika',
+    'users_delete_success' => 'Uporabnik uspešno odstranjen',
     'users_edit' => 'Uredi uporabnika',
     'users_edit_profile' => 'Uredi profil',
     'users_edit_success' => 'Uporabnik uspešno posodobljen',
-    'users_avatar' => 'uporabnikov avatar',
+    'users_avatar' => 'Uporabnikov avatar',
     'users_avatar_desc' => 'Izberi sliko, ki predstavlja uporabnika. Velikost mora biti približno 256px.',
     'users_preferred_language' => 'Izbrani jezik',
     'users_preferred_language_desc' => 'Ta možnost bo spremenila jezik, ki se uporablja za uporabniški vmesnik aplikacije. To ne bo vplivalo na nobeno vsebino, ki jo ustvari uporabnik.',
     'users_social_accounts' => 'Družbene ikone / računi',
-    '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_accounts_info' => 'Tu lahko za hitrejšo in lažjo prijavo povežete druge račune. Prekinitev povezave računa tukaj ne prekliče predhodno odobrenega dostopa. Prekličite dostop iz nastavitev profila v povezanem družabnem računu.',
     'users_social_connect' => 'Povežite račun',
     'users_social_disconnect' => 'Odklop računa',
-    'users_social_connected' => ':socialAccount  račun je bil uspešno dodan na vašem profilu',
-    'users_social_disconnected' => ':socialAccount račun je bil uspešno odstranjen iz vašega profila',
+    'users_social_connected' => ':socialAccount račun je bil uspešno dodan vašemu profilu.',
+    'users_social_disconnected' => ':socialAccount račun je bil uspešno odstranjen iz vašega profila.',
     'users_api_tokens' => 'API žeton',
     'users_api_tokens_none' => 'Nič API žetonov ni bilo ustvarjenih za uporabnika',
     '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',
     'user_api_token_name' => 'Ime',
-    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
+    'user_api_token_name_desc' => 'Dajte žetonu berljivo ime kot prihodnji opomnik o predvidenem namenu.',
     'user_api_token_expiry' => 'Datum poteka',
     'user_api_token_expiry_desc' => 'Določi datum izteka uporabnosti žetona. Po tem datumu, zahteve poslane s tem žetonom, ne bodo več delovale. 
 Če pustite to polje prazno, bo iztek uporabnosti 100.let .',
-    'user_api_token_create_secret_message' => 'Takoj po ustvarjanju tega žetona se ustvari in prikaže "Token ID" "in" Token Secret ". Skrivnost  bo prikazana samo enkrat, zato se pred nadaljevanjem prepričajte o varnosti kopirnega mesta.',
+    'user_api_token_create_secret_message' => 'Takoj po ustvarjanju tega žetona se ustvari in prikaže "Token ID" "in" Token Secret ". Skrivnost bo prikazana samo enkrat, zato se pred nadaljevanjem prepričajte o varnosti kopirnega mesta.',
     'user_api_token_create_success' => 'API žeton uspešno ustvarjen',
     'user_api_token_update_success' => 'API žeton uspešno posodobljen',
     'user_api_token' => 'API žeton',
     'user_api_token_id' => 'Žeton 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_id_desc' => 'To je sistemski identifikator, ki ga ni mogoče urejati za ta žeton in ga je treba navesti v zahtevah za API.',
     'user_api_token_secret' => 'Skrivnost žetona',
-    '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_secret_desc' => 'To je sistemsko ustvarjena skrivnost za ta žeton, ki jo bo treba navesti v zahtevah za API. To bo prikazano samo enkrat, zato kopirajte to vrednost na varno mesto.',
     'user_api_token_created' => 'Žeton ustvarjen :timeAgo',
     'user_api_token_updated' => 'Žeton posodobljen :timeAgo',
     'user_api_token_delete' => 'Briši žeton',
@@ -189,6 +240,9 @@ V tej vlogi trenutno ni dodeljen noben uporabnik',
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'danščina',
         'de' => 'Deutsch (Sie)',
@@ -197,12 +251,18 @@ V tej vlogi trenutno ni dodeljen noben uporabnik',
         'es_AR' => 'Español Argentina',
         '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',
index fec6d56027d41517204cb587261b22402475993b..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute mora biti najmanj :min znakov.',
         'array'   => ':attribute mora imeti vsaj :min elementov.',
     ],
-    'no_double_extension'  => ':attribute mora imeti samo eno razširitveno datoteko',
     'not_in'               => 'Izbrani atribut je neveljaven.',
     'not_regex'            => ':attribute oblika ni veljavna.',
     'numeric'              => 'Atribut mora biti število.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'Polje atributa je obvezno, če: vrednosti niso prisotne.',
     'required_without_all' => 'Polje atributa je obvezno, če nobena od: vrednosti ni prisotna.',
     'same'                 => 'Atribut in: drugi se morajo ujemati.',
+    'safe_url'             => 'Podana povezava morda ni varna.',
     'size'                 => [
         'numeric' => ':attribute mora biti :velikost.',
         'file'    => ':attribute mora biti :velikost KB.',
@@ -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 1cb8051b93920e18599396129598c7c0b3ef154c..a1315dcff15fae4f50a809275a8ec41069570bdf 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'tog bort hyllan',
     'bookshelf_delete_notification'    => 'Hyllan har tagits bort',
 
+    // 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',
+
     // Other
     'commented_on'                => 'kommenterade',
+    'permissions_update'          => 'uppdaterade behörigheter',
 ];
index 155e4b0025316bc4c933015370b81b50cff34157..1d1a81c7440ab1126aed82128ec4f35a9aef11cc 100644 (file)
@@ -43,7 +43,7 @@ return [
     '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.',
     'reset_password_send_button' => 'Skicka återställningslänk',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'En länk för återställning av lösenord kommer att skickas till :email om den e-postadressen finns i systemet.',
     'reset_password_success' => 'Ditt lösenord har återställts.',
     'email_reset_subject' => 'Återställ ditt lösenord till :appName',
     'email_reset_text' => 'Du får detta mail eftersom vi fått en begäran om att återställa lösenordet till ditt konto.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Lösenord satt, du har nu tillgång till :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.',
 ];
\ No newline at end of file
index 9b91e2b5ae60b9a9163f893b8a6e76bb2767382d..177a8abef1a4f44db652f6927f4aee54cbea2567 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Kopiera',
     'reply' => 'Svara',
     'delete' => 'Ta bort',
+    'delete_confirm' => 'Bekräfta radering',
     'search' => 'Sök',
     'search_clear' => 'Rensa sökning',
     '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',
 
     // Sort Options
     'sort_options' => 'Sorteringsalternativ',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Sortera stigande',
     'sort_descending' => 'Sortera fallande',
     'sort_name' => 'Namn',
+    'sort_default' => 'Standard',
     'sort_created_at' => 'Skapad',
     'sort_updated_at' => 'Uppdaterad',
 
@@ -54,6 +61,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,17 +71,25 @@ return [
     'breadcrumb' => 'Brödsmula',
 
     // Header
+    'header_menu_expand' => 'Expandera sidhuvudsmenyn',
     'profile_menu' => 'Profilmeny',
     'view_profile' => 'Visa profil',
     'edit_profile' => 'Redigera profil',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Mörkt läge',
+    'light_mode' => 'Ljust Läge',
 
     // Layout tabs
     'tab_info' => 'Information',
+    'tab_info_label' => 'Flik: Visa sekundär information',
     'tab_content' => 'Innehåll',
+    '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:',
     'email_rights' => 'Alla rättigheter är reserverade',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Integritetspolicy',
+    'terms_of_service' => 'Användarvillkor',
 ];
index 5e4085dec792eac2d657d2f016418e76eefa3892..68b141945e1d30d130762981d1aa2789941d152a 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Ladda fler',
     'image_image_name' => 'Bildnamn',
     'image_delete_used' => 'Den här bilden används på nedanstående sidor.',
-    'image_delete_confirm' => 'Klicka på "ta bort" en gång till för att bekräfta att du vill ta bort bilden.',
+    'image_delete_confirm_text' => 'Är du säker på att du vill radera denna bild?',
     'image_select_image' => 'Välj bild',
     'image_dropzone' => 'Släpp bilder här eller klicka för att ladda upp',
     'images_deleted' => 'Bilder borttagna',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Redigera kod',
     'code_language' => 'Språk',
     'code_content' => 'Kod',
+    'code_session_history' => 'Sessionshistorik',
     'code_save' => 'Spara',
 ];
index e4938fbfd542426543025997042482452a840c4d..30142658397bbac63f9ee6cf9f7aa00f4dcb7693 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => 'Skapad :timeLength av :user',
     'meta_updated' => 'Uppdaterad :timeLength',
     'meta_updated_name' => 'Uppdaterad :timeLength av :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',
@@ -33,12 +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' => 'Ägare',
 
     // Search
     'search_results' => 'Sökresultat',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Inga sidor matchade sökningen',
     'search_for_term' => 'Sök efter :term',
     'search_more' => 'Fler resultat',
-    'search_filters' => 'Sökfilter',
+    'search_advanced' => 'Avancerad sök',
+    'search_terms' => 'Söktermer',
     'search_content_type' => 'Innehållstyp',
     'search_exact_matches' => 'Exakta matchningar',
     'search_tags' => 'Taggar',
@@ -57,6 +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' => 'Ägs av mig',
     'search_date_options' => 'Datumalternativ',
     'search_updated_before' => 'Uppdaterade före',
     'search_updated_after' => 'Uppdaterade efter',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Skapa nytt kapitel',
     'chapters_delete' => 'Radera kapitel',
     'chapters_delete_named' => 'Radera kapitlet :chapterName',
-    'chapters_delete_explain' => 'Du håller på att ta bort kapitlet \':chapterName\'. Alla sidor kommer att flyttas direkt in i den aktuella boken istället.',
+    'chapters_delete_explain' => 'Detta kommer att ta bort kapitlet med namnet \':chapterName\'. Alla sidor som finns inom detta kapitel kommer också att raderas.',
     'chapters_delete_confirm' => 'Är du säker på att du vill ta bort det här kapitlet?',
     'chapters_edit' => 'Redigera kapitel',
     'chapters_edit_named' => 'Redigera kapitel :chapterName',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Sidrevisioner',
     'pages_revisions_named' => 'Sidrevisioner för :pageName',
     'pages_revision_named' => 'Sidrevision för :pageName',
+    'pages_revision_restored_from' => 'Återställd från #:id; :summary',
     'pages_revisions_created_by' => 'Skapad av',
     'pages_revisions_date' => 'Revisionsdatum',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Ladda upp fil',
     'attachments_link' => 'Bifoga länk',
     'attachments_set_link' => 'Ange länk',
-    'attachments_delete_confirm' => 'Klicka på "ta bort" igen för att bekräfta att du vill ta bort bilagan.',
+    'attachments_delete' => 'Är du säker på att du vill ta bort bilagan?',
     'attachments_dropzone' => 'Släpp filer här eller klicka för att ladda upp',
     'attachments_no_files' => 'Inga filer har laddats upp',
     'attachments_explain_link' => 'Du kan bifoga en länk om du inte vill ladda upp en fil. Detta kan vara en länk till en annan sida eller till en fil i molnet.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Länk till fil',
     'attachments_link_url_hint' => 'URL till sida eller fil',
     'attach' => 'Bifoga',
+    'attachments_insert_link' => 'Lägg till bilagelänk till sida',
     'attachments_edit_file' => 'Redigera fil',
     'attachments_edit_file_name' => 'Filnamn',
     'attachments_edit_drop_upload' => 'Släpp filer här eller klicka för att ladda upp och skriva över',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index adf22af1d18f9f50c7d221083bb5b0d8e5730256..b8216a42fa5d71c4de13befff703dff85ec15926 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Filuppladdningen har tagits ut.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Fel i sidmatchning vid uppdatering av bilaga',
     'attachment_not_found' => 'Bilagan hittades ej',
 
     // Pages
@@ -84,6 +83,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 9f5545f36d96df981358b9c4e73277def5c32772..640309b8848fe4396f3463079f4463887c59c05f 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Lösenord måste vara minst sex tecken långa och anges likadant två gånger.',
     'user' => "Det finns ingen användare med den e-postadressen.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Lösenordsåterställningstoken är ogiltig för denna e-postadress.',
     'sent' => 'Vi har mailat dig en länk för att återställa ditt lösenord!',
     'reset' => 'Ditt lösenord har blivit återställt!',
 
index 488f52cc3c054207ea6911ef80bd00dd4b428085..1aa51ee38d8df0019ad90fb4c3dadb6c9207e5f5 100644 (file)
@@ -37,6 +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' => '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.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Ignorera bilder i revisioner',
+    '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?',
     'maint_image_cleanup_success' => 'Hittade och raderade :count bilder som potentiellt inte används!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Testmejl',
     'maint_send_test_email_mail_greeting' => 'E-postleverans verkar fungera!',
     'maint_send_test_email_mail_text' => 'Grattis! Eftersom du fick detta e-postmeddelande verkar dina e-postinställningar vara korrekt konfigurerade.',
+    'maint_recycle_bin_desc' => 'Borttagna hyllor, böcker, kapitel & sidor skickas till papperskorgen så att de kan återställas eller raderas permanent. Äldre objekt i papperskorgen kan automatiskt tas bort efter ett tag beroende på systemkonfiguration.',
+    'maint_recycle_bin_open' => 'Öppna papperskorgen',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Återställ',
+    'recycle_bin_contents_empty' => 'Papperskorgen är för närvarande tom',
+    'recycle_bin_empty' => 'Töm papperskorgen',
+    'recycle_bin_empty_confirm' => 'Detta kommer permanent att förstöra alla objekt i papperskorgen inklusive innehåll som finns i varje objekt. Är du säker du vill tömma papperskorgen?',
+    'recycle_bin_destroy_confirm' => 'Denna åtgärd kommer att permanent ta bort detta objekt, tillsammans med alla underordnade element som anges nedan, från systemet och du kommer inte att kunna återställa detta innehåll. Är du säker på att du vill ta bort objektet permanent?',
+    'recycle_bin_destroy_list' => 'Objekt som ska förstöras',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Auditlogg',
+    'audit_desc' => 'Denna granskningslogg visar en lista över aktiviteter som spåras i systemet. Denna lista är ofiltrerad till skillnad från liknande aktivitetslistor i systemet där behörighetsfilter tillämpas.',
+    'audit_event_filter' => 'Händelse Filter',
+    'audit_event_filter_no_filter' => 'Inget filter',
+    'audit_deleted_item' => 'Raderat objekt',
+    'audit_deleted_item_name' => 'Namn: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Roller',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Administratörer har automatisk tillgång till allt innehåll men dessa alternativ kan visa och dölja vissa gränssnittselement',
     'role_all' => 'Alla',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Användarprofil',
     'users_add_new' => 'Lägg till användare',
     'users_search' => 'Sök användare',
+    'users_latest_activity' => 'Senaste aktivitet',
     'users_details' => 'Användarinformation',
     'users_details_desc' => 'Ange ett visningsnamn och en e-postadress för den här användaren. E-postadressen kommer att användas vid inloggningen.',
     'users_details_desc_no_email' => 'Ange ett visningsnamn för den här användaren så att andra kan känna igen den.',
@@ -138,6 +185,9 @@ 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' => 'Ö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',
@@ -157,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',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Katalanska',
         'cs' => 'Česky',
         'da' => 'Danska',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 5da78b2457f14fe13f48c476aee929650e0a8cdd..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute måste vara minst :min tecken.',
         'array'   => ':attribute måste ha minst :min poster.',
     ],
-    'no_double_extension'  => ':attribute får bara ha ett filtillägg.',
     'not_in'               => 'Vald :attribute är inte giltig',
     'not_regex'            => 'Formatet på :attribute är ogiltigt.',
     'numeric'              => ':attribute måste vara ett nummer.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':attribute är obligatoriskt när :values inte finns.',
     'required_without_all' => ':attribute är obligatirskt när ingen av :values finns.',
     'same'                 => ':attribute och :other måste stämma överens.',
+    'safe_url'             => 'Den angivna länken kanske inte är säker.',
     'size'                 => [
         'numeric' => ':attribute måste vara :size.',
         'file'    => ':attribute måste vara :size kilobyte.',
@@ -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 68c58b92ba4e9243fd1afae7eca30ed89feab4df..e87bd11a5e343173fadf78e62a042e1ca0579a4a 100644 (file)
@@ -33,6 +33,7 @@ return [
     'copy' => 'Copy',
     'reply' => 'Reply',
     'delete' => 'Delete',
+    'delete_confirm' => 'Confirm Deletion',
     'search' => 'Search',
     'search_clear' => 'Clear Search',
     'reset' => 'Reset',
index d8e8981fb5fcf6ba8d15993453d4f8f2d07df970..48a0a32faa38c4821a9d71dda9a5fb4f97d35232 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Load More',
     'image_image_name' => 'Image Name',
     'image_delete_used' => 'This image is used in the pages below.',
-    'image_delete_confirm' => 'Click delete again to confirm you want to delete this image.',
+    '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',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Edit Code',
     'code_language' => 'Code Language',
     'code_content' => 'Code Content',
+    'code_session_history' => 'Session History',
     'code_save' => 'Save Code',
 ];
index 6bbc723b0abfc1e6b1f69e270bbb9d2afdbe851b..f64867a56c31736a1730d58c51f3fe0c088364d1 100644 (file)
@@ -47,7 +47,8 @@ return [
     'search_no_pages' => 'No pages matched this search',
     'search_for_term' => 'Search for :term',
     'search_more' => 'More Results',
-    'search_filters' => 'Search Filters',
+    'search_advanced' => 'Advanced Search',
+    'search_terms' => 'Search Terms',
     'search_content_type' => 'Content Type',
     'search_exact_matches' => 'Exact Matches',
     'search_tags' => 'Tag Searches',
@@ -255,7 +256,7 @@ return [
     'attachments_upload' => 'Upload File',
     'attachments_link' => 'Attach Link',
     'attachments_set_link' => 'Set Link',
-    'attachments_delete_confirm' => 'Click delete again to confirm you want to delete this attachment.',
+    '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.',
@@ -264,6 +265,7 @@ return [
     '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',
index 06a5285f56fc4ce11e6642549a1002b1bacae698..79024e482ed69efa633116592f9b7c83a0bcc93a 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'The file upload has timed out.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Page mismatch during attachment update',
     'attachment_not_found' => 'Attachment not found',
 
     // Pages
index f1345c743b6dcc2bdfc7555774627195ebcd4109..2bd314cf0f28561f9a2f296b373483df42480f89 100644 (file)
@@ -81,6 +81,20 @@ return [
     '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.',
 
+    // 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_item' => 'Related Item',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
+
     // Role Settings
     'roles' => 'Roles',
     'role_user_roles' => 'User Roles',
@@ -106,6 +120,7 @@ return [
     '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',
index 76b57a2a3b58ddb8ef41e0562c5187359cc6e542..f4af9bc6f010ff1eed03b1cbc77e95d1490e53b4 100644 (file)
@@ -78,7 +78,6 @@ return [
         'string'  => 'The :attribute must be at least :min characters.',
         'array'   => 'The :attribute must have at least :min items.',
     ],
-    'no_double_extension'  => 'The :attribute must only have a single file extension.',
     'not_in'               => 'The selected :attribute is invalid.',
     'not_regex'            => 'The :attribute format is invalid.',
     'numeric'              => 'The :attribute must be a number.',
index cb8101461e8801bae98fb6e6078c4e235df90e98..9a6e60cd40b7559f7c102a96909e920891eff756 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'kitaplığı sildi',
     'bookshelf_delete_notification'    => 'Kitaplık Başarıyla Silindi',
 
+    // 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',
+
     // Other
     'commented_on'                => 'yorum yaptı',
+    'permissions_update'          => 'güncellenmiş izinler',
 ];
index 6a0e2c1b5ea6bce020997932adb93ebb4699f395..0ce90d4d08013d32c594536c4b50f211b9218b8c 100644 (file)
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Şifreniz ayarlandı, artık :appName uygulamasına giriş yapabilirsiniz!',
+
+    // 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.',
 ];
\ No newline at end of file
index 587640672fb289ad10985bf09fb66b4f2047b0a2..1f19a62f406d4093d6b6bc94c078e1ee03d84863 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Kopyala',
     'reply' => 'Yanıtla',
     'delete' => 'Sil',
+    'delete_confirm' => 'Silmeyi Onayla',
     'search' => 'Ara',
     'search_clear' => 'Aramayı Temizle',
     'reset' => 'Sıfırla',
     'remove' => 'Kaldır',
     'add' => 'Ekle',
+    'configure' => 'Configure',
     'fullscreen' => 'Tam Ekran',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => 'Sıralama Seçenekleri',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'Artan Sıralama',
     'sort_descending' => 'Azalan Sıralama',
     'sort_name' => 'İsim',
+    'sort_default' => 'Default',
     'sort_created_at' => 'Oluşturulma Tarihi',
     'sort_updated_at' => 'Güncelleme Tarihi',
 
@@ -54,6 +61,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,7 @@ return [
     'breadcrumb' => 'Gezinti Menüsü',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profil Menüsü',
     'view_profile' => 'Profili Görüntüle',
     'edit_profile' => 'Profili Düzenle',
@@ -71,9 +80,16 @@ return [
 
     // Layout tabs
     'tab_info' => 'Bilgi',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'İçerik',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => '":actionText" butonuna tıklamada sorun yaşıyorsanız, aşağıda bulunan bağlantıyı kopyalayıp tarayıcınıza yapıştırın:',
     'email_rights' => 'Tüm hakları saklıdır',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
 ];
index 4f15735410ce192a9bc4083a0a8d22d7830ce064..009c48f23bb8f1bd495acc4ad6fcd73859056455 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Devamını Göster',
     'image_image_name' => 'Görsel Adı',
     'image_delete_used' => 'Bu görsel aşağıda bulunan sayfalarda kullanılmış.',
-    'image_delete_confirm' => 'Bu görseli silmek istediğinize emin misiniz?',
+    'image_delete_confirm_text' => 'Bu resmi silmek istediğinizden emin misiniz?',
     'image_select_image' => 'Görsel Seç',
     'image_dropzone' => 'Görselleri sürükleyin ya da seçin',
     'images_deleted' => 'Görseller Silindi',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Kodu Düzenle',
     'code_language' => 'Kod Dili',
     'code_content' => 'Kod İçeriği',
+    'code_session_history' => 'Oturum Geçmişi',
     'code_save' => 'Kodu Kaydet',
 ];
index df0dadb8927e6d3843f614a069f0801ee68650f3..770918dd096c7901c7e67e0142ca215ad3bcde28 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => ':user tarafından :timeLength oluşturuldu',
     'meta_updated' => ':timeLength güncellendi',
     'meta_updated_name' => ':user tarafından :timeLength güncellendi',
+    'meta_owned_name' => 'Owned by :user',
     'entity_select' => 'Öge Seçimi',
     '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',
@@ -33,12 +36,14 @@ return [
     'export_html' => 'Web Dosyası',
     'export_pdf' => 'PDF Dosyası',
     'export_text' => 'Düz Metin Dosyası',
+    'export_md' => 'Markdown File',
 
     // Permissions and restrictions
     'permissions' => 'İzinler',
     'permissions_intro' => 'Etkinleştirildikten sonra bu izinler, diğer bütün izinlerden öncelikli olacaktır.',
     'permissions_enable' => 'Özelleştirilmiş Yetkileri Etkinleştir',
     'permissions_save' => 'İzinleri Kaydet',
+    'permissions_owner' => 'Sahip',
 
     // Search
     'search_results' => 'Arama Sonuçları',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Bu aramayla ilgili herhangi bir sayfa bulunamadı',
     'search_for_term' => ':term için Ara',
     'search_more' => 'Daha Fazla Sonuç',
-    'search_filters' => 'Arama Filtreleri',
+    'search_advanced' => 'Gelişmiş Arama',
+    'search_terms' => 'Terimleri Ara',
     'search_content_type' => 'İçerik Türü',
     'search_exact_matches' => 'Tam Eşleşmeler',
     'search_tags' => 'Etiket Aramaları',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'İzinler ayarlanmış',
     'search_created_by_me' => 'Oluşturduklarım',
     'search_updated_by_me' => 'Güncellediklerim',
+    'search_owned_by_me' => 'Owned by me',
     'search_date_options' => 'Tarih Seçenekleri',
     'search_updated_before' => 'Önce güncellendi',
     'search_updated_after' => 'Sonra güncellendi',
@@ -92,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.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Yeni Bölüm Oluştur',
     'chapters_delete' => 'Bölümü Sil',
     'chapters_delete_named' => ':chapterName Bölümünü Sil',
-    'chapters_delete_explain' => 'Bu işlem sonunda \':chapterName\' bölümü silinecek ve bu bölüme ait bütün sayfalar direkt olarak ana kitaba aktarılacaktır.',
+    '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' => 'Bölümü silmek istediğinize emin misiniz?',
     'chapters_edit' => 'Bölümü Düzenle',
     'chapters_edit_named' => ':chapterName Bölümünü Düzenle',
@@ -207,6 +215,7 @@ return [
     'pages_revisions' => 'Sayfa Revizyonları',
     'pages_revisions_named' => ':pageName için Sayfa Revizyonları',
     'pages_revision_named' => ':pageName için Sayfa Revizyonu',
+    'pages_revision_restored_from' => 'Restored from #:id; :summary',
     'pages_revisions_created_by' => 'Revize Eden',
     'pages_revisions_date' => 'Revizyon Tarihi',
     'pages_revisions_number' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Dosya Yükle',
     'attachments_link' => 'Link Ekle',
     'attachments_set_link' => 'Bağlantıyı Ata',
-    'attachments_delete_confirm' => 'Eki silmek istediğinize emin misiniz?',
+    'attachments_delete' => 'Bu eki silmek istediğinize emin misiniz?',
     'attachments_dropzone' => 'Dosyaları sürükleyin veya seçin',
     'attachments_no_files' => 'Hiçbir dosya yüklenmedi',
     'attachments_explain_link' => 'Eğer dosya yüklememeyi tercih ederseniz bağlantı ekleyebilirsiniz. Bu bağlantı başka bir sayfanın veya bulut depolamadaki bir dosyanın bağlantısı olabilir.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Dosya bağlantısı',
     'attachments_link_url_hint' => 'Dosyanın veya sitenin url adresi',
     'attach' => 'Ekle',
+    'attachments_insert_link' => 'Sayfaya Bağlantı Ekle',
     'attachments_edit_file' => 'Dosyayı Düzenle',
     'attachments_edit_file_name' => 'Dosya Adı',
     'attachments_edit_drop_upload' => 'Üzerine yazılacak dosyaları sürükleyin veya seçin',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index af2a47623475c5cf804358eec5608976149abe4b..5048b079cf6045597d15d90b59347fa06d1fcce2 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Dosya yüklemesi zaman aşımına uğradı',
 
     // Attachments
-    'attachment_page_mismatch' => 'Ek güncellemesi sırasında sayfa uyuşmazlığı yaşandı',
     'attachment_not_found' => 'Ek bulunamadı',
 
     // Pages
@@ -84,6 +83,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 545c91fe0c3b1046c028ff1d191f66edcedb2340..aca4a062891bd01dc51074583f43af85a55ca27b 100755 (executable)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Ana Sayfa',
     'app_homepage_desc' => 'Varsayılan görünüm yerine ana sayfada görünmesi için bir görünüm seçin. Sayfa izinleri, burada seçeceğiniz sayfalar için yok sayılacaktır.',
     'app_homepage_select' => 'Bir sayfa seçin',
+    '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' => 'Yorumları Devre Dışı Bırak',
     'app_disable_comments_toggle' => 'Yorumları devre dışı bırak',
     'app_disable_comments_desc' => 'Bütün sayfalar için yorumları devre dışı bırakır. <br> Mevcut yorumlar gösterilmeyecektir.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Revizyonlardaki görselleri yoksay',
+    '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?',
     'maint_image_cleanup_success' => 'Muhtemelen kullanılmayan :count adet görsel bulundu ve silindi!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Deneme E-postası',
     'maint_send_test_email_mail_greeting' => 'E-posta iletimi çalışıyor gibi görünüyor!',
     'maint_send_test_email_mail_text' => 'Tebrikler! Eğer bu e-posta bildirimini alıyorsanız, e-posta ayarlarınız doğru bir şekilde ayarlanmış demektir.',
+    'maint_recycle_bin_desc' => 'Silinen raflar, kitaplar, bölümler ve sayfalar geri dönüşüm kutusuna gönderilir, böylece geri yüklenebilir veya kalıcı olarak silinebilir. Geri dönüşüm kutusundaki daha eski öğeler, sistem yapılandırmasına bağlı olarak bir süre sonra otomatik olarak kaldırılabilir.',
+    'maint_recycle_bin_open' => 'Geri Dönüşüm Kutusunu Aç',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Geri Yükle',
+    'recycle_bin_contents_empty' => 'Geri dönüşüm kutusu boş',
+    'recycle_bin_empty' => 'Geri Dönüşüm Kutusunu Boşalt',
+    'recycle_bin_empty_confirm' => 'Bu işlem, her bir öğenin içinde bulunan içerik de dahil olmak üzere geri dönüşüm kutusundaki tüm öğeleri kalıcı olarak imha edecektir. Geri dönüşüm kutusunu boşaltmak istediğinizden emin misiniz?',
+    'recycle_bin_destroy_confirm' => 'Bu işlem, bu öğeyi kalıcı olarak ve aşağıda listelenen alt öğelerle birlikte sistemden silecek ve bu içeriği geri yükleyemeyeceksiniz. Bu öğeyi kalıcı olarak silmek istediğinizden emin misiniz?',
+    'recycle_bin_destroy_list' => 'Kalıcı Olarak Silinecek Öğeler',
+    '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.',
+
+    // Audit Log
+    'audit' => 'Denetim Kaydı',
+    'audit_desc' => 'Bu denetim günlüğü, sistemde izlenen etkinliklerin bir listesini görüntüler. Bu liste, izin filtrelerinin uygulandığı sistemdeki benzer etkinlik listelerinden farklı olarak filtrelenmez.',
+    'audit_event_filter' => 'Etkinlik Filtresi',
+    'audit_event_filter_no_filter' => 'Filtre Yok',
+    'audit_deleted_item' => 'Silinen Öge',
+    'audit_deleted_item_name' => 'Isim: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Roller',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Yöneticilere otomatik olarak bütün içeriğe erişim yetkisi verilir ancak bu seçenekler, kullanıcı arayüzündeki bazı seçeneklerin gösterilmesine veya gizlenmesine neden olabilir.',
     'role_all' => 'Hepsi',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Kullanıcı Profili',
     'users_add_new' => 'Yeni Kullanıcı Ekle',
     'users_search' => 'Kullanıcı Ara',
+    'users_latest_activity' => 'Son Etkinlik',
     'users_details' => 'Kullanıcı Detayları',
     'users_details_desc' => 'Bu kullanıcı için gösterilecek bir isim ve e-posta adresi belirleyin. Buraya yazacağınız e-posta adresi, uygulamaya giriş yaparken kullanılacaktır.',
     'users_details_desc_no_email' => 'Diğer kullanıcılar tarafından tanınabilmesi için bir isim belirleyin.',
@@ -138,7 +185,10 @@ return [
     'users_delete_named' => ':userName kullanıcısını sil ',
     'users_delete_warning' => 'Bu işlem \':userName\' kullanıcısını sistemden tamamen silecektir.',
     'users_delete_confirm' => 'Bu kullanıcıyı tamamen silmek istediğinize emin misiniz?',
-    'users_delete_success' => 'Kullanıcılar başarıyla silindi.',
+    'users_migrate_ownership' => 'Sahipliği Taşıyın',
+    'users_migrate_ownership_desc' => 'Başka bir kullanıcının şu anda bu kullanıcıya ait olan tüm öğelerin sahibi olmasını istiyorsanız buradan bir kullanıcı seçin.',
+    'users_none_selected' => 'Hiçbir kullanıcı seçilmedi',
+    'users_delete_success' => 'Kullanıcı başarıyla kaldırıldı',
     'users_edit' => 'Kullanıcıyı Düzenle',
     'users_edit_profile' => 'Profili Düzenle',
     'users_edit_success' => 'Kullanıcı başarıyla güncellendi',
@@ -157,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',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Danca',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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)',
         'pl' => 'Polski',
+        'pt' => 'Português',
         'pt_BR' => 'Português do Brasil',
         'ru' => 'Русский',
         'sk' => 'Slovensky',
index 801ec89da46acb9d2cc040835f7be9762d89fb48..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute, en az :min karakter içermelidir.',
         'array'   => ':attribute, en az :min öge içermelidir.',
     ],
-    'no_double_extension'  => ':attribute, sadece tek bir dosya tipinde olmalıdır.',
     'not_in'               => 'Seçili :attribute geçersiz.',
     'not_regex'            => ':attribute formatı geçersiz.',
     'numeric'              => ':attribute, bir sayı olmalıdır.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => ':values değerinin bulunmuyor olması, :attribute alanını zorunlu kılar.',
     'required_without_all' => ':values değerlerinden hiçbirinin bulunmuyor olması, :attribute alanını zorunlu kılar.',
     'same'                 => ':attribute ve :other eşleşmelidir.',
+    'safe_url'             => 'Sağlanan bağlantı güvenli olmayabilir.',
     'size'                 => [
         'numeric' => ':attribute, :size boyutunda olmalıdır.',
         'file'    => ':attribute, :size kilobayt 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 900ccd2b84fca49e450dad2c695aa128aa10e595..88130b90fdf2be780cb5f1508a9beb2eccafb598 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'видалив книжкову полицю',
     'bookshelf_delete_notification'    => 'Книжкову полицю успішно видалено',
 
+    // 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',
+
     // Other
     'commented_on'                => 'прокоментував',
+    'permissions_update'          => 'оновив дозволи',
 ];
index 3b6e354c42dc7512b56e260ccc9c06e7f01ce24f..52625b60fba1150d76e716bc26347842ec1a6eec 100644 (file)
@@ -43,7 +43,7 @@ return [
     'reset_password' => 'Скинути пароль',
     'reset_password_send_instructions' => 'Введіть адресу електронної пошти нижче, і вам буде надіслано електронне повідомлення з посиланням на зміну пароля.',
     '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' => 'Ви отримали цей електронний лист, оскільки до нас надійшов запит на скидання пароля для вашого облікового запису.',
@@ -73,5 +73,40 @@ return [
     'user_invite_page_welcome' => 'Ласкаво просимо до :appName!',
     'user_invite_page_text' => 'Для завершення процесу створення облікового запису та отримання доступу вам потрібно задати пароль, який буде використовуватися для входу в :appName в майбутньому.',
     'user_invite_page_confirm_button' => 'Підтвердити пароль',
-    'user_invite_success' => 'Встановлено пароль, тепер у вас є доступ до :appName!'
+    'user_invite_success' => 'Встановлено пароль, тепер у вас є доступ до :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.',
 ];
\ No newline at end of file
index 225429ffa1aa594b52ee0abcb9a2efc9d3d1ccf5..734c566e55f68e3075367b134fa52f1c47e50965 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Копіювати',
     'reply' => 'Відповісти',
     'delete' => 'Видалити',
+    'delete_confirm' => 'Підтвердити видалення',
     'search' => 'Шукати',
     'search_clear' => 'Очистити пошук',
     'reset' => 'Скинути',
     'remove' => 'Видалити',
     'add' => 'Додати',
-    'fullscreen' => 'Fullscreen',
+    'configure' => 'Configure',
+    'fullscreen' => 'На весь екран',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
+    'next' => 'Next',
+    'previous' => 'Previous',
 
     // Sort Options
     'sort_options' => 'Параметри сортування',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => 'За зростанням',
     'sort_descending' => 'За спаданням',
     'sort_name' => 'Ім\'я',
+    'sort_default' => 'За замовчуванням',
     'sort_created_at' => 'Дата створення',
     'sort_updated_at' => 'Дата оновлення',
 
@@ -54,6 +61,7 @@ return [
     'no_activity' => 'Немає активності для показу',
     'no_items' => 'Немає доступних елементів',
     'back_to_top' => 'Повернутися до початку',
+    'skip_to_main_content' => 'Skip to main content',
     'toggle_details' => 'Подробиці',
     'toggle_thumbnails' => 'Мініатюри',
     'details' => 'Деталі',
@@ -63,17 +71,25 @@ return [
     'breadcrumb' => 'Навігація',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Меню профілю',
     'view_profile' => 'Переглянути профіль',
     'edit_profile' => 'Редагувати профіль',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Темний режим',
+    'light_mode' => 'Світлий режим',
 
     // Layout tabs
     'tab_info' => 'Інфо',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Вміст',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => 'Якщо у вас виникають проблеми при натисканні кнопки ":actionText", скопіюйте та вставте URL у свій веб-браузер:',
     'email_rights' => 'Всі права захищені',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Політика приватності',
+    'terms_of_service' => 'Умови використання',
 ];
index 0cd7e8804d55beda2ba7d7a79d68511ba2d30640..a37d196eb522c7e2afa6af82a75feba12b637f1e 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Завантажити ще',
     'image_image_name' => 'Назва зображення',
     'image_delete_used' => 'Це зображення використовується на наступних сторінках.',
-    'image_delete_confirm' => 'Натисніть кнопку Видалити ще раз, щоб підтвердити, що хочете видалити це зображення.',
+    'image_delete_confirm_text' => 'Ви дійсно хочете видалити це зображення?',
     'image_select_image' => 'Вибрати зображення',
     'image_dropzone' => 'Перетягніть зображення, або натисніть тут для завантаження',
     'images_deleted' => 'Зображень видалено',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Редагувати код',
     'code_language' => 'Мова коду',
     'code_content' => 'Вміст коду',
+    'code_session_history' => 'Історія сесії',
     'code_save' => 'Зберегти Код',
 ];
index 7f941718972c2c5a8b2abeb1f041e0cd4ce2cced..427acc5b180be1a2ed8c1a4214544e6185142dfd 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => ':user створив :timeLength',
     'meta_updated' => 'Оновлено :timeLength',
     'meta_updated_name' => ':user оновив :timeLength',
+    'meta_owned_name' => 'Власник :user',
     'entity_select' => 'Вибір об\'єкта',
     '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' => 'Немає недавно оновлених сторінок',
@@ -33,12 +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' => 'Власник',
 
     // Search
     'search_results' => 'Результати пошуку',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Немає сторінок, які відповідають цьому пошуку',
     'search_for_term' => 'Шукати :term',
     'search_more' => 'Більше результатів',
-    'search_filters' => 'Фільтри пошуку',
+    'search_advanced' => 'Розширений пошук',
+    'search_terms' => 'Пошукові фрази',
     'search_content_type' => 'Тип вмісту',
     'search_exact_matches' => 'Точна відповідність',
     'search_tags' => 'Пошукові теги',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => 'Налаштування дозволів',
     'search_created_by_me' => 'Створено мною',
     'search_updated_by_me' => 'Оновлено мною',
+    'search_owned_by_me' => 'Належать мені',
     'search_date_options' => 'Параметри дати',
     'search_updated_before' => 'Оновлено до',
     'search_updated_after' => 'Оновлено після',
@@ -92,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' => 'Це застосовує поточні налаштування дозволів цієї книжкової полиці до всіх книг, що містяться всередині. Перш ніж активувати, переконайтесь що будь-які зміни дозволів цієї книжкової полиці були збережені.',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => 'Створити новий розділ',
     'chapters_delete' => 'Видалити розділ',
     'chapters_delete_named' => 'Видалити розділ :chapterName',
-    'chapters_delete_explain' => 'Ця дія видалить розділ з назвою \':chapterName\'. Всі сторінки будуть вилучені, та додані безпосередньо до батьківської книги.',
+    'chapters_delete_explain' => 'Це видалить розділ під назвою \':chapterName\'. Усі сторінки, що існують у цьому розділі, також будуть видалені.',
     'chapters_delete_confirm' => 'Ви впевнені, що хочете видалити цей розділ?',
     'chapters_edit' => 'Редагувати розділ',
     'chapters_edit_named' => 'Редагувати розділ :chapterName',
@@ -207,6 +215,7 @@ return [
     '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' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Завантажити файл',
     'attachments_link' => 'Приєднати посилання',
     'attachments_set_link' => 'Встановити посилання',
-    'attachments_delete_confirm' => 'Натисніть кнопку Видалити ще раз, щоб підтвердити, що ви хочете видалити це вкладення.',
+    'attachments_delete' => 'Дійсно хочете видалити це вкладення?',
     'attachments_dropzone' => 'Перетягніть файли, або натисніть тут щоб прикріпити файл',
     'attachments_no_files' => 'Файли не завантажені',
     'attachments_explain_link' => 'Ви можете приєднати посилання, якщо не бажаєте завантажувати файл. Це може бути посилання на іншу сторінку або посилання на файл у хмарі.',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Посилання на файл',
     'attachments_link_url_hint' => 'URL-адреса сайту або файлу',
     'attach' => 'Приєднати',
+    'attachments_insert_link' => 'Додати посилання на вкладення',
     'attachments_edit_file' => 'Редагувати файл',
     'attachments_edit_file_name' => 'Назва файлу',
     'attachments_edit_drop_upload' => 'Перетягніть файли, або натисніть тут щоб завантажити та перезаписати',
@@ -311,4 +321,4 @@ return [
     'revision_restore_confirm' => 'Дійсно відновити цю версію? Вміст поточної сторінки буде замінено.',
     'revision_delete_success' => 'Версія видалена',
     'revision_cannot_delete_latest' => 'Неможливо видалити останню версію.'
-];
\ No newline at end of file
+];
index f3aa299ed72ffcc103778a639ab3871e2c01c32c..c7d2545f995565c6935b0b8325629401422180b5 100644 (file)
@@ -13,7 +13,7 @@ return [
     'email_already_confirmed' => 'Електронна пошта вже підтверджена, спробуйте увійти.',
     '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-доступ невдалий, використовуючи задані параметри dn та password',
     'ldap_extension_not_installed' => 'Розширення PHP LDAP не встановлено',
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Тайм-аут при завантаженні файлу',
 
     // Attachments
-    'attachment_page_mismatch' => 'Невідповідність сторінки при оновленні вкладень',
     'attachment_not_found' => 'Вкладення не знайдено',
 
     // Pages
@@ -83,21 +82,24 @@ return [
     // 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',
+    '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 зараз недоступний',
     '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 250bec3fbe9907ce7188b8f7fc08bc8f5f5c6053..90c31777c9876fb8c1f3da3ada0fd54998256957 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Пароль повинен містити не менше восьми символів і збігатись з підтвердженням.',
     'user' => "Ми не можемо знайти користувача з цією адресою електронної пошти.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Токен скидання пароля недійсний для цієї адреси електронної пошти.',
     'sent' => 'Ми надіслали Вам електронний лист із посиланням для скидання пароля!',
     'reset' => 'Ваш пароль скинуто!',
 
index d7dd8397bcefb064eea5836cd2b52276dd1a8279..2c96d4a2b5119f98fe746eca8cd5645e51a0b5b9 100644 (file)
@@ -15,7 +15,7 @@ return [
     'app_customization' => 'Налаштування',
     'app_features_security' => 'Особливості та безпека',
     'app_name' => 'Назва програми',
-    'app_name_desc' => 'ЦÑ\8f Ð½Ð°Ð·Ð²Ð° Ð²Ñ\96добÑ\80ажаÑ\94Ñ\82Ñ\8cÑ\81Ñ\8f Ñ\83 Ð·Ð°Ð³Ð¾Ð»Ð¾Ð²ÐºÑ\83 Ñ\82а Ñ\83 Ð²сіх листах.',
+    'app_name_desc' => 'ЦÑ\8f Ð½Ð°Ð·Ð²Ð° Ð¿Ð¾ÐºÐ°Ð·Ñ\83Ñ\94Ñ\82Ñ\8cÑ\81Ñ\8f Ñ\83 Ð·Ð°Ð³Ð¾Ð»Ð¾Ð²ÐºÑ\83 Ñ\82а Ð² Ñ\83сіх листах.',
     'app_name_header' => 'Показати назву програми в заголовку',
     'app_public_access' => 'Публічнй доступ',
     'app_public_access_desc' => 'Увімкнення цієї опції дозволить відвідувачам, які не увійшли в систему, отримати доступ до вмісту у вашому екземплярі BookStack.',
@@ -35,32 +35,37 @@ return [
     'app_primary_color' => 'Основний колір програми',
     'app_primary_color_desc' => 'Колір потрібно вказати у hex-форматі. <br>Залиште порожнім, щоб використати стандартний колір.',
     'app_homepage' => 'Домашня сторінка програми',
-    'app_homepage_desc' => 'Ð\92ибеÑ\80Ñ\96Ñ\82Ñ\8c Ñ\81Ñ\82оÑ\80Ñ\96нкÑ\83, Ñ\8fка Ð²Ñ\96добÑ\80ажаÑ\82имеÑ\82Ñ\8cÑ\81Ñ\8f Ð½Ð° Ð´Ð¾Ð¼Ð°Ñ\88нÑ\96й Ñ\81Ñ\82оÑ\80Ñ\96нÑ\86Ñ\96 Ð·Ð°Ð¼Ñ\96Ñ\81Ñ\82Ñ\8c Ð¿ÐµÑ\80еглÑ\8fдÑ\83 Ð·Ð° Ñ\83мовÑ\87анням. Права на сторінку не враховуються для вибраних сторінок.',
+    'app_homepage_desc' => 'Ð\92ибеÑ\80Ñ\96Ñ\82Ñ\8c Ñ\81Ñ\82оÑ\80Ñ\96нкÑ\83, Ñ\8fка Ð¿Ð¾ÐºÐ°Ð·Ñ\83ваÑ\82имеÑ\82Ñ\8cÑ\81Ñ\8f Ð½Ð° Ð´Ð¾Ð¼Ð°Ñ\88нÑ\96й Ñ\81Ñ\82оÑ\80Ñ\96нÑ\86Ñ\96 Ð·Ð°Ð¼Ñ\96Ñ\81Ñ\82Ñ\8c Ð¿ÐµÑ\80еглÑ\8fдÑ\83 Ð·Ð° Ð·Ð°Ð¼Ð¾Ð²Ñ\87Ñ\83ванням. Права на сторінку не враховуються для вибраних сторінок.',
     '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' => 'Вимкнути коментарі на всіх сторінках програми. Існуючі коментарі не відображаються.',
 
     // Color settings
     '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.',
+    'content_colors_desc' => 'Встановлює кольори для всіх елементів в ієрархії організації сторінок. Рекомендуємо вибирати кольори із яскравістю, схожою на кольори за замовчуванням, для кращої читабельності.',
     'bookshelf_color' => 'Колір полиці',
     'book_color' => 'Колір книги',
-    'chapter_color' => 'Chapter Color',
+    'chapter_color' => 'Колір глави',
     'page_color' => 'Колір сторінки',
     'page_draft_color' => 'Колір чернетки',
 
     // Registration Settings
     'reg_settings' => 'Реєстрація',
-    'reg_enable' => 'Дозволити реєстрацію',
+    'reg_enable' => 'Дозвіл на реєстрацію',
     'reg_enable_toggle' => 'Дозволити реєстрацію',
     'reg_enable_desc' => 'При включенні реєстрації відвідувач зможе зареєструватися як користувач програми. Після реєстрації їм надається єдина роль користувача за замовчуванням.',
     'reg_default_role' => 'Роль користувача за умовчанням після реєстрації',
-    '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' => 'Цей параметр ігнорується, якщо активна зовнішня автентифікація LDAP або SAML. Облікові записи користувачів для неіснуючих учасників будуть створені автоматично, якщо аутентифікація у зовнішній системі буде успішною.',
     'reg_email_confirmation' => 'Підтвердження електронною поштою',
     'reg_email_confirmation_toggle' => 'Необхідне підтвердження електронною поштою',
     'reg_confirm_email_desc' => 'Якщо використовується обмеження домену, то підтвердження електронною поштою буде потрібно, а нижче значення буде проігноровано.',
-    'reg_confirm_restrict_domain' => 'Ð\9eбмежиÑ\82и по домену',
+    'reg_confirm_restrict_domain' => 'Ð\9eбмеженнÑ\8f по домену',
     'reg_confirm_restrict_domain_desc' => 'Введіть список розділених комами доменів електронної пошти, до яких ви хочете обмежити реєстрацію. Користувачам буде надіслано електронне повідомлення для підтвердження своєї адреси, перш ніж дозволяти взаємодіяти з додатком. <br> Зауважте, що користувачі зможуть змінювати свої електронні адреси після успішної реєстрації.',
     'reg_confirm_restrict_domain_placeholder' => 'Не встановлено обмежень',
 
@@ -68,7 +73,7 @@ return [
     'maint' => 'Обслуговування',
     'maint_image_cleanup' => 'Очищення зображень',
     'maint_image_cleanup_desc' => "Сканує вміст сторінки та версій, щоб перевірити, які зображення та малюнки в даний час використовуються, а також які зображення зайві. Переконайтеся, що ви створили повну резервну копію бази даних та зображення, перш ніж запускати це.",
-    'maint_image_cleanup_ignore_revisions' => 'Ігнорувати зображення в версіях',
+    'maint_delete_images_only_in_revisions' => 'Також видалити зображення, що існують лише в старих версіях сторінки',
     'maint_image_cleanup_run' => 'Запустити очищення',
     'maint_image_cleanup_warning' => ':count потенційно невикористаних зображень було знайдено. Ви впевнені, що хочете видалити ці зображення?',
     'maint_image_cleanup_success' => ':count потенційно невикористані зображення знайдено і видалено!',
@@ -80,6 +85,44 @@ return [
     '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_desc' => 'Тут ви можете відновити видалені елементи, або назавжди видалити їх із системи. Цей список нефільтрований, на відміну від подібних списків активності в системі, де застосовуються фільтри дозволів.',
+    'recycle_bin_deleted_item' => 'Виадлений елемент',
+    'recycle_bin_deleted_parent' => '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' => 'Restore Parent',
+    'recycle_bin_destroy_notification' => 'Видалено :count елементів із кошика.',
+    'recycle_bin_restore_notification' => 'Відновлено :count елементів із кошика.',
+
+    // Audit Log
+    '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 Address',
+    'audit_table_date' => 'Дата активності',
+    'audit_date_from' => 'Діапазон дат від',
+    'audit_date_to' => 'Діапазон дат до',
 
     // Role Settings
     'roles' => 'Ролі',
@@ -96,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' => 'Керування користувачами',
@@ -103,9 +147,11 @@ return [
     'role_manage_entity_permissions' => 'Керування всіма правами на книги, розділи та сторінки',
     'role_manage_own_entity_permissions' => 'Керування дозволами на власну книгу, розділ та сторінки',
     'role_manage_page_templates' => 'Управління шаблонами сторінок',
-    'role_access_api' => 'Access system API',
+    'role_access_api' => 'Доступ до системного API',
     'role_manage_settings' => 'Керування налаштуваннями програми',
+    'role_export_content' => 'Export content',
     'role_asset' => 'Дозволи',
+    'roles_system_warning' => 'Майте на увазі, що доступ до будь-якого з вищезазначених трьох дозволів може дозволити користувачеві змінювати власні привілеї або привілеї інших в системі. Ролі з цими дозволами призначайте лише довіреним користувачам.',
     'role_asset_desc' => 'Ці дозволи контролюють стандартні доступи всередині системи. Права на книги, розділи та сторінки перевизначать ці дозволи.',
     'role_asset_admins' => 'Адміністратори автоматично отримують доступ до всього вмісту, але ці параметри можуть відображати або приховувати параметри інтерфейсу користувача.',
     'role_all' => 'Все',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Профіль користувача',
     'users_add_new' => 'Додати нового користувача',
     'users_search' => 'Пошук користувачів',
+    'users_latest_activity' => 'Остання активність',
     'users_details' => 'Відомості про користувача',
     'users_details_desc' => 'Встановіть ім\'я та електронну адресу для цього користувача. Адреса електронної пошти буде використана для входу до програми.',
     'users_details_desc_no_email' => 'Встановіть ім\'я для цього користувача, щоб інші могли його розпізнати.',
@@ -131,14 +178,17 @@ return [
     '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' => 'Цей ідентифікатор використовується для ідентифікації цього користувача під час взаємодії із зовнішньою системою автентифікації.',
     'users_password_warning' => 'Тільки якщо ви хочете змінити свій пароль, заповніть поля нижче:',
     'users_system_public' => 'Цей користувач представляє будь-яких гостьових користувачів, які відвідують ваш екземпляр. Його не можна використовувати для входу, але він призначається автоматично.',
     'users_delete' => 'Видалити користувача',
     'users_delete_named' => 'Видалити користувача :userName',
     'users_delete_warning' => 'Це повне видалення цього користувача з ім\'ям \':userName\' з системи.',
     'users_delete_confirm' => 'Ви впевнені, що хочете видалити цього користувача?',
-    'users_delete_success' => 'Користувачі успішно видалені',
+    'users_migrate_ownership' => 'Право власності при міграції',
+    'users_migrate_ownership_desc' => 'Виберіть тут користувача, якщо ви хочете, щоб інший користувач став власником усіх елементів, які зараз належать цьому користувачеві.',
+    'users_none_selected' => 'Не вибрано жодного користувача',
+    'users_delete_success' => 'Користувача успішно видалено',
     'users_edit' => 'Редагувати користувача',
     'users_edit_profile' => 'Редагувати профіль',
     'users_edit_success' => 'Користувача успішно оновлено',
@@ -152,32 +202,36 @@ return [
     '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' => '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',
-    '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' => 'Ідентифікатор (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 успішно видалено',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Dansk',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 47f15fe7fcbdb5a63be8889665695b9574662683..a2c1b9890c2d10d3596907da86550cfb3ffb4bde 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.',
@@ -78,7 +79,6 @@ return [
         'string'  => 'Текст в полі :attribute повинен містити не менше :min символів.',
         'array'   => 'Поле :attribute повинне містити не менше :min елементів.',
     ],
-    'no_double_extension'  => 'Поле :attribute повинне містити тільки одне розширення файлу.',
     'not_in'               => 'Вибране для :attribute значення не коректне.',
     'not_regex'            => 'Формат поля :attribute не вірний.',
     'numeric'              => 'Поле :attribute повинно містити число.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'Поле :attribute є обов\'язковим для заповнення, коли :values не вказано.',
     'required_without_all' => 'Поле :attribute є обов\'язковим для заповнення, коли :values не вказано.',
     'same'                 => 'Поля :attribute та :other мають збігатися.',
+    'safe_url'             => 'Надане посилання може бути небезпечним.',
     'size'                 => [
         'numeric' => 'Поле :attribute має бути довжини :size.',
         'file'    => 'Файл в полі :attribute має бути розміром :size кілобайт.',
@@ -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 19fae850a4ffd73aa36f8086be79167bb5ee0d21..255ce38aa10937d1e2414089bd7d41d58f209ff6 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => 'đã xóa giá sách',
     'bookshelf_delete_notification'    => 'Giá sách đã được xóa thành công',
 
+    // 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',
+
     // Other
     'commented_on'                => 'đã bình luận về',
+    'permissions_update'          => 'các quyền đã được cập nhật',
 ];
index bd13572cb884d7d5570a62f40ca24a87800f41dd..e95d26ac6129bfda9c689c09b07d08e85ed1f5ad 100644 (file)
@@ -43,7 +43,7 @@ return [
     '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.',
     'reset_password_send_button' => 'Gửi liên kết đặt lại mật khẩu',
-    'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.',
+    'reset_password_sent' => 'Một đường dẫn đặt lại mật khẩu sẽ được gửi tới :email nếu địa chỉ email đó tồn tại trong hệ thống.',
     'reset_password_success' => 'Mật khẩu đã được đặt lại thành công.',
     'email_reset_subject' => 'Đặt lại mật khẩu của :appName',
     '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.',
@@ -73,5 +73,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!'
+    'user_invite_success' => 'Mật khẩu đã được thiết lập, bạn có quyền truy cập đến :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' => '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' => '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.',
 ];
\ No newline at end of file
index 2b56b4639448fe29d45113f001208551e21cd01b..f118d34c3a9e6cb04bda0e0b421976b23d97362d 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => 'Sao chép',
     'reply' => 'Trả lời',
     'delete' => 'Xóa',
+    'delete_confirm' => 'Xác nhận Xóa',
     'search' => 'Tìm kiếm',
     'search_clear' => 'Xoá tìm kiếm',
     '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 đó',
 
     // Sort Options
     'sort_options' => 'Tùy Chọn Sắp Xếp',
@@ -46,6 +52,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' => 'Mặc định',
     'sort_created_at' => 'Ngày Tạo',
     'sort_updated_at' => 'Ngày cập nhật',
 
@@ -54,6 +61,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,17 +71,25 @@ return [
     'breadcrumb' => 'Đường dẫn liên kết',
 
     // Header
+    'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Menu Hồ sơ',
     'view_profile' => 'Xem Hồ sơ',
     'edit_profile' => 'Sửa Hồ sơ',
-    'dark_mode' => 'Dark Mode',
-    'light_mode' => 'Light Mode',
+    'dark_mode' => 'Chế độ Tối',
+    'light_mode' => 'Chế độ Sáng',
 
     // Layout tabs
     'tab_info' => 'Thông tin',
+    'tab_info_label' => 'Tab: Show Secondary Information',
     'tab_content' => 'Nội dung',
+    'tab_content_label' => 'Tab: Show Primary Content',
 
     // Email Content
     'email_action_help' => 'Nếu bạn đang có vấn đề trong việc bấm nút ":actionText", sao chép và dán địa chỉ URL dưới đây vào trình duyệt web:',
     'email_rights' => 'Bản quyền đã được bảo hộ',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Chính Sách Quyền Riêng Tư',
+    'terms_of_service' => 'Điều khoản Dịch vụ',
 ];
index c1a9b343d3f4299f85fd3d9abe7c04bf1e6a10ac..8a061625cd6128ad48e34c76439b368b4c4b2de5 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => 'Hiện thêm',
     'image_image_name' => 'Tên Ảnh',
     'image_delete_used' => 'Ảnh này được sử dụng trong các trang dưới đây.',
-    'image_delete_confirm' => 'Bấm nút xóa lần nữa để xác nhận bạn muốn xóa ảnh này.',
+    'image_delete_confirm_text' => 'Bạn có chắc chắn muốn xóa hình ảnh này?',
     'image_select_image' => 'Chọn Ảnh',
     'image_dropzone' => 'Thả các ảnh hoặc bấm vào đây để tải lên',
     'images_deleted' => 'Các ảnh đã được xóa',
@@ -29,5 +29,6 @@ return [
     'code_editor' => 'Sửa Mã',
     'code_language' => 'Ngôn ngữ Mã',
     'code_content' => 'Nội dung Mã',
+    'code_session_history' => 'Lịch sử Phiên',
     'code_save' => 'Lưu Mã',
 ];
index 31fbaaf7ff2acc622db97e94753d0398b5043d7e..0cbfd27a43b1e83a1bc02bb588da5c23e5810e96 100644 (file)
@@ -22,10 +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' => 'Đượ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',
@@ -33,12 +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' => 'Chủ sở hữu',
 
     // Search
     'search_results' => 'Kết quả Tìm kiếm',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => 'Không trang nào khớp với tìm kiếm này',
     'search_for_term' => 'Tìm kiếm cho :term',
     'search_more' => 'Thêm kết quả',
-    'search_filters' => 'Bộ lọc Tìm kiếm',
+    'search_advanced' => 'Tìm kiếm Nâng cao',
+    'search_terms' => 'Cụm từ Tìm kiếm',
     'search_content_type' => 'Kiểu Nội dung',
     'search_exact_matches' => 'Hoàn toàn trùng khớp',
     'search_tags' => 'Tìm kiếm Tag',
@@ -57,6 +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' => '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',
@@ -92,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.',
@@ -145,7 +153,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' => 'Chức năng này sẽ xóa chương với tên \':chapterName\'. Tất cả các trang sẽ bị loại bỏ và thêm trực tiếp vào sách chứa nó.',
+    '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',
@@ -207,6 +215,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' => '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' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => 'Tải lên Tập tin',
     'attachments_link' => 'Đính kèm Liên kết',
     'attachments_set_link' => 'Đặt Liên kết',
-    'attachments_delete_confirm' => 'Bấm xóa lần nữa để xác nhận bạn muốn xóa đính kèm này.',
+    'attachments_delete' => 'Bạn có chắc chắn muốn xóa tập tin đính kèm này?',
     'attachments_dropzone' => 'Thả các tập tin hoặc bấm vào đây để đính kèm một tập tin',
     'attachments_no_files' => 'Không có tập tin nào được tải lên',
     'attachments_explain_link' => 'Bạn có thể đính kèm một liên kết nếu bạn lựa chọn không tải lên tập tin. Liên kết này có thể trỏ đến một trang khác hoặc một tập tin ở trên mạng (đám mây).',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => 'Liên kết đến tập tin',
     'attachments_link_url_hint' => 'URL của trang hoặc tập tin',
     'attach' => 'Đính kèm',
+    'attachments_insert_link' => 'Thêm Đường dẫn Tập tin đính kèm vào Trang',
     'attachments_edit_file' => 'Sửa tập tin',
     'attachments_edit_file_name' => 'Tên tệp tin',
     'attachments_edit_drop_upload' => 'Thả tập tin hoặc bấm vào đây để tải lên và ghi đè',
@@ -311,4 +321,4 @@ return [
     '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.'
-];
\ No newline at end of file
+];
index e93f74e64ee37f1db1716328c70981e64b355fc2..cfd2b974638912795a50464b65e2bd4fd3adb43f 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => 'Đã quá thời gian tải lên tệp tin.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Trang không trùng khớp khi cập nhật đính kèm',
     'attachment_not_found' => 'Không tìm thấy đính kèm',
 
     // Pages
@@ -83,7 +82,10 @@ return [
     // Error pages
     '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' => 'If you expected this page to exist, you might not have permission to view it.',
+    '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 21242e003a3ec3dbe7b75aab91f58d581b323222..65b42b4d80fdd300169c5d0a940d7830e3399b85 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => 'Mật khẩu phải có tối thiểu 8 ký tự và và phải trùng với mật khẩu xác nhận.',
     'user' => "Chúng tôi không tìm thấy người dùng với địa chỉ email đó.",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => 'Mã token đặt lại mật khẩu cho địa chỉ email này không hợp lệ.',
     'sent' => 'Chúng tôi đã gửi email chứa liên kết đặt lại mật khẩu cho bạn!',
     'reset' => 'Mật khẩu của bạn đã được đặt lại!',
 
index 4f7469228626d2e8f3ae3abd6d65c2fa5a5e54ad..7dbed9018bba197ddff5e32121b1c1daf752523a 100644 (file)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Trang chủ Ứng dụng',
     'app_homepage_desc' => 'Chọn hiển thị để hiện tại trang chủ thay cho hiển thị mặc định. Quyền cho trang được bỏ qua cho các trang được chọn.',
     'app_homepage_select' => 'Chọn một trang',
+    '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' => 'Tắt bình luận',
     'app_disable_comments_toggle' => 'Tắt bình luận',
     'app_disable_comments_desc' => 'Tắt các bình luận trên tất cả các trang của ứng dụng. <br> Các bình luận đã tồn tại sẽ không được hiển thị.',
@@ -68,7 +73,7 @@ return [
     '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_ignore_revisions' => 'Bỏ qua ảnh trong phiên bản chỉnh sửa',
+    '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?',
     'maint_image_cleanup_success' => ':count ảnh có thể không được sử dụng đã được tìm thấy và xóa!',
@@ -80,6 +85,44 @@ return [
     'maint_send_test_email_mail_subject' => 'Thử Email',
     'maint_send_test_email_mail_greeting' => 'Chức năng gửi email có vẻ đã hoạt động!',
     'maint_send_test_email_mail_text' => 'Chúc mừng! Khi bạn nhận được email thông báo này, cài đặt email của bạn có vẻ đã được cấu hình đúng.',
+    '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' => 'Mở Thùng Rác',
+
+    // Recycle Bin
+    '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',
+    'recycle_bin_restore' => 'Khôi phục',
+    'recycle_bin_contents_empty' => 'Thùng rác hiện đang trống',
+    'recycle_bin_empty' => 'Dọn dẹp Thùng Rác',
+    '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_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.',
+
+    // 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' => 'Không Lọc',
+    'audit_deleted_item' => 'Mục Đã Xóa',
+    'audit_deleted_item_name' => 'Tên: :name',
+    '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',
 
     // Role Settings
     'roles' => 'Quyền',
@@ -96,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',
@@ -105,7 +149,9 @@ 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.',
     'role_asset_admins' => 'Quản trị viên được tự động cấp quyền truy cập đến toàn bộ nội dung, tuy nhiên các tùy chọn đó có thể hiện hoặc ẩn tùy chọn giao diện.',
     'role_all' => 'Tất cả',
@@ -121,6 +167,7 @@ return [
     'user_profile' => 'Hồ sơ người dùng',
     'users_add_new' => 'Thêm người dùng mới',
     'users_search' => 'Tìm kiếm người dùng',
+    'users_latest_activity' => 'Hoạt động mới nhất',
     'users_details' => 'Chi tiết người dùng',
     'users_details_desc' => 'Hiển thị tên và địa chỉ email cho người dùng này. Địa chỉ email sẽ được sử dụng để đăng nhập vào ứng dụng.',
     'users_details_desc_no_email' => 'Đặt tên cho người dùng này để giúp người dùng khác nhận ra họ.',
@@ -138,6 +185,9 @@ return [
     'users_delete_named' => 'Xóa người dùng :userName',
     'users_delete_warning' => 'Chức năng này sẽ hoàn toàn xóa người dùng với tên \':userName\' từ hệ thống.',
     'users_delete_confirm' => 'Bạn có chắc muốn xóa người dùng này không?',
+    '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' => 'Chưa chọn người dùng',
     'users_delete_success' => 'Người dùng đã được xóa thành công',
     'users_edit' => 'Sửa người dùng',
     'users_edit_profile' => 'Sửa Hồ sơ',
@@ -157,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',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => 'Català',
         'cs' => 'Česky',
         'da' => 'Đan Mạch',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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',
index 030885408be647e4cf5004edbdf798c196f74da4..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.',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute phải có tối thiểu :min ký tự.',
         'array'   => ':attribute phải có tối thiểu :min mục.',
     ],
-    'no_double_extension'  => ':attribute chỉ được có một định dạng mở rộng duy nhất.',
     'not_in'               => ':attribute đã chọn không hợp lệ.',
     'not_regex'            => 'Định dạng của :attribute không hợp lệ.',
     'numeric'              => ':attribute phải là một số.',
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'Trường :attribute là bắt buộc khi :values không tồn tại.',
     'required_without_all' => 'Trường :attribute là bắt buộc khi không có bất cứ :values nào tồn tại.',
     'same'                 => ':attribute và :other phải trùng khớp với nhau.',
+    'safe_url'             => 'Đường dẫn cung cấp có thể không an toàn.',
     'size'                 => [
         'numeric' => ':attribute phải có cỡ :size.',
         'file'    => ':attribute phải có cỡ :size KB.',
@@ -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 676a1dd92db0c0c2556ae858b6a0ada0c236b472..65e7c3583b2fb7ac1ce40c36421014e77cd561c2 100644 (file)
@@ -43,6 +43,15 @@ return [
     'bookshelf_delete'                 => '删除了书架',
     'bookshelf_delete_notification'    => '书架已成功删除',
 
+    // Favourites
+    'favourite_add_notification' => '":name" 已添加到你的收藏',
+    'favourite_remove_notification' => '":name" 已从你的收藏中删除',
+
+    // MFA
+    'mfa_setup_method_notification' => '多重身份认证设置成功',
+    'mfa_remove_method_notification' => '多重身份认证已成功移除',
+
     // Other
     'commented_on'                => '评论',
+    'permissions_update'          => '权限已更新',
 ];
index e1d25078db7bdedc75960cf38b015fb61002d57a..a31ee20e75b53c9f214ecc7a5ebe3c48961b5b33 100644 (file)
@@ -13,10 +13,10 @@ return [
     'sign_up' => '注册',
     'log_in' => '登录',
     'log_in_with' => '以:socialDriver登录',
-    'sign_up_with' => '注册:socialDriver',
+    'sign_up_with' => '通过 :socialDriver 账号登录',
     'logout' => '注销',
 
-    'name' => 'å§\93å\90\8d',
+    'name' => 'å\90\8dç§°',
     'username' => '用户名',
     'email' => 'Email地址',
     'password' => '密码',
@@ -26,11 +26,11 @@ return [
     '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,并点击确认。',
@@ -43,7 +43,7 @@ return [
     'reset_password' => '重置密码',
     'reset_password_send_instructions' => '在下面输入您的Email地址,您将收到一封带有密码重置链接的邮件。',
     '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' => '您收到此电子邮件是因为我们收到了您的帐户的密码重置请求。',
@@ -73,5 +73,40 @@ return [
     'user_invite_page_welcome' => '欢迎来到 :appName!',
     'user_invite_page_text' => '要完成您的帐户并获得访问权限,您需要设置一个密码,该密码将在以后访问时用于登录 :appName。',
     'user_invite_page_confirm_button' => '确认密码',
-    'user_invite_success' => '已设置密码,您现在可以访问 :appName!'
+    'user_invite_success' => '已设置密码,您现在可以访问 :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' => '多重身份认证已设置,请使用新配置的方法重新登录。',
 ];
\ No newline at end of file
index bc9e94d18057f41775a1b25fcd654b50c63cb319..6c2fa668bed9aab35c9064a9cce303c3c4325932 100644 (file)
@@ -33,12 +33,18 @@ return [
     'copy' => '复制',
     'reply' => '回复',
     'delete' => '删除',
+    'delete_confirm' => '确认删除',
     'search' => '搜索',
     'search_clear' => '清除搜索',
     'reset' => '重置',
     'remove' => '删除',
     'add' => '添加',
+    'configure' => '配置',
     'fullscreen' => '全屏',
+    'favourite' => '收藏',
+    'unfavourite' => '取消收藏',
+    'next' => '下一页',
+    'previous' => '上一页',
 
     // Sort Options
     'sort_options' => '排序选项',
@@ -46,6 +52,7 @@ return [
     'sort_ascending' => '升序',
     'sort_descending' => '降序',
     'sort_name' => '名称',
+    'sort_default' => '默认',
     'sort_created_at' => '创建时间',
     'sort_updated_at' => '更新时间',
 
@@ -54,6 +61,7 @@ return [
     'no_activity' => '没有活动要显示',
     'no_items' => '没有可用的项目',
     'back_to_top' => '回到顶部',
+    'skip_to_main_content' => '跳转到主要内容',
     'toggle_details' => '显示/隐藏详细信息',
     'toggle_thumbnails' => '显示/隐藏缩略图',
     'details' => '详细信息',
@@ -63,17 +71,25 @@ return [
     'breadcrumb' => '面包屑导航',
 
     // Header
+    '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' => '信息',
+    'tab_info_label' => '标签:显示次要信息',
     'tab_content' => '内容',
+    'tab_content_label' => '标签:显示主要内容',
 
     // Email Content
     'email_action_help' => '如果您无法点击“:actionText”按钮,请将下面的网址复制到您的浏览器中打开:',
     'email_rights' => '版权所有',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => '隐私政策',
+    'terms_of_service' => '服务条款',
 ];
index 54d0fb085731550136fa218feb6628def6d11e5a..ab0b7cb469ec5a25179e1b26902cf47fd2401aa2 100644 (file)
@@ -15,7 +15,7 @@ return [
     'image_load_more' => '显示更多',
     'image_image_name' => '图片名称',
     'image_delete_used' => '该图像用于以下页面。',
-    'image_delete_confirm' => '如果你想删除它,请再次按下按钮。',
+    'image_delete_confirm_text' => '您确认要删除此图片吗?',
     'image_select_image' => '选择图片',
     'image_dropzone' => '拖放图片或点击此处上传',
     'images_deleted' => '图片已删除',
@@ -29,5 +29,6 @@ return [
     'code_editor' => '编辑代码',
     'code_language' => '编程语言',
     'code_content' => '代码内容',
+    'code_session_history' => '会话历史',
     'code_save' => '保存代码',
 ];
index 5c614b079b07f036bc357c2ffe57329176a277dc..277bcfb32a6b9da249d59cf57145c87446dfcbe8 100644 (file)
@@ -22,10 +22,13 @@ return [
     'meta_created_name' => '由 :user 创建于 :timeLength',
     'meta_updated' => '更新于 :timeLength',
     'meta_updated_name' => '由 :user 更新于 :timeLength',
+    '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' => '最近没有页面被更新',
@@ -33,12 +36,14 @@ return [
     'export_html' => '网页文件',
     'export_pdf' => 'PDF文件',
     'export_text' => '纯文本文件',
+    'export_md' => 'Markdown 文件',
 
     // Permissions and restrictions
     'permissions' => '权限',
     'permissions_intro' => '本设置优先于每个用户角色本身所具有的权限。',
     'permissions_enable' => '启用自定义权限',
     'permissions_save' => '保存权限',
+    'permissions_owner' => '拥有者',
 
     // Search
     'search_results' => '搜索结果',
@@ -47,7 +52,8 @@ return [
     'search_no_pages' => '没有找到相匹配的页面',
     'search_for_term' => '“:term”的搜索结果',
     'search_more' => '更多结果',
-    'search_filters' => '过滤搜索结果',
+    'search_advanced' => '高级搜索',
+    'search_terms' => '搜索关键词',
     'search_content_type' => '种类',
     'search_exact_matches' => '精确匹配',
     'search_tags' => '标签搜索',
@@ -57,6 +63,7 @@ return [
     'search_permissions_set' => '权限设置',
     'search_created_by_me' => '我创建的',
     'search_updated_by_me' => '我更新的',
+    'search_owned_by_me' => '我拥有的',
     'search_date_options' => '日期选项',
     'search_updated_before' => '在此之前更新',
     'search_updated_after' => '在此之后更新',
@@ -92,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' => '这会将此书架的当前权限设置应用于其中包含的所有图书。 在激活之前,请确保已保存对此书架权限的任何更改。',
@@ -100,7 +108,7 @@ return [
     // Books
     'book' => '图书',
     'books' => '图书',
-    'x_books' => ':count本书',
+    'x_books' => ':count 本书',
     'books_empty' => '不存在已创建的书',
     'books_popular' => '热门图书',
     'books_recent' => '最近的书',
@@ -145,7 +153,7 @@ return [
     'chapters_create' => '创建章节',
     'chapters_delete' => '删除章节',
     'chapters_delete_named' => '删除章节「:chapterName」',
-    'chapters_delete_explain' => '这将删除章节「:chapterName」。所有的页面将被删除并添加到其所在的书籍。',
+    'chapters_delete_explain' => '这将删除名为“:chapterName”的章节。本章节中存在的所有页面也将被删除。',
     'chapters_delete_confirm' => '您确定要删除此章节吗?',
     'chapters_edit' => '编辑章节',
     'chapters_edit_named' => '编辑章节「:chapterName」',
@@ -207,6 +215,7 @@ return [
     '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' => '#',
@@ -255,7 +264,7 @@ return [
     'attachments_upload' => '上传文件',
     'attachments_link' => '附加链接',
     'attachments_set_link' => '设置链接',
-    'attachments_delete_confirm' => '确认您想要删除此附件后,请点击删除。',
+    'attachments_delete' => '您确定要删除此附件吗?',
     'attachments_dropzone' => '删除文件或点击此处添加文件',
     'attachments_no_files' => '尚未上传文件',
     'attachments_explain_link' => '如果您不想上传文件,则可以附加链接,这可以是指向其他页面的链接,也可以是指向云端文件的链接。',
@@ -264,6 +273,7 @@ return [
     'attachments_link_url' => '链接到文件',
     'attachments_link_url_hint' => '网站或文件的网址',
     'attach' => '附加',
+    'attachments_insert_link' => '将附加链接添加到页面',
     'attachments_edit_file' => '编辑文件',
     'attachments_edit_file_name' => '文件名',
     'attachments_edit_drop_upload' => '删除文件或点击这里上传并覆盖',
@@ -303,7 +313,7 @@ return [
     'comment_deleted_success' => '评论已删除',
     'comment_created_success' => '评论已添加',
     'comment_updated_success' => '评论已更新',
-    'comment_delete_confirm' => '确定要删除这条评论?',
+    'comment_delete_confirm' => '确定要删除这条评论?',
     'comment_in_reply_to' => '回复 :commentId',
 
     // Revision
@@ -311,4 +321,4 @@ return [
     'revision_restore_confirm' => '您确定要恢复到此修订版吗?恢复后原有内容将会被替换。',
     'revision_delete_success' => '修订删除',
     'revision_cannot_delete_latest' => '无法删除最新版本。'
-];
\ No newline at end of file
+];
index 21034f3991359f681c6a4cb13c87e9b50476ac61..7ad049ed40bdbfc9decfb985f30b25d6922646e1 100644 (file)
@@ -46,7 +46,6 @@ return [
     'file_upload_timeout' => '文件上传已超时。',
 
     // Attachments
-    'attachment_page_mismatch' => '附件更新期间的页面不匹配',
     'attachment_not_found' => '找不到附件',
 
     // Pages
@@ -83,7 +82,10 @@ return [
     // 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现在正在关闭',
index ac3f5e2ae3c5218a9929be45b03f32f3ef434ca7..8d8272ee01fd9f774e2b30c4cf2a5f35a2677405 100644 (file)
@@ -8,7 +8,7 @@ return [
 
     'password' => '密码必须至少包含六个字符并与确认相符。',
     'user' => "使用该Email地址的用户不存在。",
-    'token' => 'The password reset token is invalid for this email address.',
+    'token' => '重置密码链接无法发送至此邮件地址。',
     'sent' => '我们已经通过Email发送您的密码重置链接!',
     'reset' => '您的密码已被重置!',
 
index 6a23b090663c50b454ea0c3317c30469d18ef869..910b9614d0f11c040eddca20c7d6c011456d3039 100755 (executable)
@@ -15,8 +15,8 @@ return [
     'app_customization' => '定制',
     'app_features_security' => '功能与安全',
     'app_name' => '站点名称',
-    'app_name_desc' => '此名称将在网页头部和Email中显示。',
-    'app_name_header' => '在网页头部显示应用名?',
+    'app_name_desc' => '此名称将在网页头部和系统发送的电子邮件中显示。',
+    'app_name_header' => '在网页头部显示站点名称?',
     'app_public_access' => '访问权限',
     'app_public_access_desc' => '启用此选项将允许未登录的用户访问站点内容。',
     'app_public_access_desc_guest' => '可以通过“访客”用户来控制公共访问者的访问。',
@@ -35,8 +35,13 @@ return [
     '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"将显示为“服务条款”。',
+    'app_footer_links_label' => '链接标签',
+    'app_footer_links_url' => '链接 URL',
+    'app_footer_links_add' => '添加页脚链接',
     'app_disable_comments' => '禁用评论',
     'app_disable_comments_toggle' => '禁用评论',
     'app_disable_comments_desc' => '在站点的所有页面上禁用评论,现有评论也不会显示出来。',
@@ -57,18 +62,18 @@ return [
     'reg_enable_desc' => '启用注册后,用户将可以自己注册为站点用户。 注册后,他们将获得一个默认的单一用户角色。',
     'reg_default_role' => '注册后的默认用户角色',
     'reg_enable_external_warning' => '当启用外部LDAP或者SAML认证时,上面的选项会被忽略。当使用外部系统认证认证成功时,将自动创建非现有会员的用户账户。',
-    'reg_email_confirmation' => '邮确认',
+    '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_ignore_revisions' => '忽略修订记录中的图像',
+    'maint_image_cleanup_desc' => "扫描页面和修订内容以检查哪些图片是正在使用的以及哪些图片是多余的。确保在运行前完整备份数据库和图片。",
+    'maint_delete_images_only_in_revisions' => '同时删除只存在于旧的页面修订中的图片',
     'maint_image_cleanup_run' => '运行清理',
     'maint_image_cleanup_warning' => '发现了 :count 张可能未使用的图像。您确定要删除这些图像吗?',
     'maint_image_cleanup_success' => '找到并删除了 :count 张可能未使用的图像!',
@@ -79,7 +84,45 @@ return [
     '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_send_test_email_mail_text' => '恭喜!您收到了此邮件通知,您的电子邮件设置看起来已配置正确。',
+    'maint_recycle_bin_desc' => '被删除的书架、书籍、章节和页面会被存入回收站,您可以还原或永久删除它们。回收站中较旧的项目可能会在系统设置的一段时间后被自动删除。',
+    'maint_recycle_bin_open' => '打开回收站',
+
+    // 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_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' => '角色',
@@ -96,6 +139,7 @@ return [
     'role_details' => '角色详细信息',
     'role_name' => '角色名',
     'role_desc' => '角色简述',
+    'role_mfa_enforced' => '需要多重身份认证',
     'role_external_auth_id' => '外部身份认证ID',
     'role_system' => '系统权限',
     'role_manage_users' => '管理用户',
@@ -105,7 +149,9 @@ return [
     'role_manage_page_templates' => '管理页面模板',
     'role_access_api' => '访问系统 API',
     'role_manage_settings' => '管理App设置',
+    'role_export_content' => '导出内容',
     'role_asset' => '资源许可',
+    'roles_system_warning' => '请注意,具有上述三个权限中的任何一个都可以允许用户更改自己的特权或系统中其他人的特权。 只将具有这些权限的角色分配给受信任的用户。',
     'role_asset_desc' => '对系统内资源的默认访问许可将由这些权限控制。单独设置在书籍,章节和页面上的权限将覆盖这里的权限设定。',
     'role_asset_admins' => '管理员可自动获得对所有内容的访问权限,但这些选项可能会显示或隐藏UI选项。',
     'role_all' => '全部的',
@@ -121,6 +167,7 @@ return [
     'user_profile' => '用户资料',
     'users_add_new' => '添加用户',
     'users_search' => '搜索用户',
+    'users_latest_activity' => '最后活动',
     'users_details' => '用户详细资料',
     'users_details_desc' => '设置该用户的显示名称和电子邮件地址。 该电子邮件地址将用于登录本站。',
     'users_details_desc_no_email' => '设置此用户的昵称,以便其他人识别。',
@@ -131,14 +178,17 @@ return [
     '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' => '此用户代表访问您的App的任何访客。它不能用于登录,而是自动分配。',
     'users_delete' => '删除用户',
     'users_delete_named' => '删除用户 :userName',
     'users_delete_warning' => '这将从系统中完全删除名为 \':userName\' 的用户。',
     'users_delete_confirm' => '您确定要删除这个用户?',
-    'users_delete_success' => '用户删除成功。',
+    'users_migrate_ownership' => '迁移拥有权',
+    'users_migrate_ownership_desc' => '如果您想要当前用户拥有的全部项目转移到另一个用户(更改拥有者),请在此处选择一个用户。',
+    'users_none_selected' => '没有选中用户',
+    'users_delete_success' => '已成功移除用户',
     'users_edit' => '编辑用户',
     'users_edit_profile' => '编辑资料',
     'users_edit_success' => '用户更新成功',
@@ -157,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 令牌',
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => '保加利亚语',
+        'bs' => 'Bosanski',
+        'ca' => '加泰罗尼亚语',
         'cs' => 'Česky',
         'da' => '丹麦',
         'de' => 'Deutsch (Sie)',
@@ -193,12 +250,18 @@ return [
         'es_AR' => 'Español Argentina',
         '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)',
         'pl' => 'Polski',
+        'pt' => 'Português',
         'pt_BR' => 'Português do Brasil',
         'ru' => 'Русский',
         'sk' => 'Slovensky',
index e0bc55523883715bec8cddee26907f364979445d..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之间。',
@@ -78,7 +79,6 @@ return [
         'string'  => ':attribute 至少为:min个字符。',
         'array'   => ':attribute 至少有:min项。',
     ],
-    'no_double_extension'  => ':attribute 必须具有一个扩展名。',
     'not_in'               => '选中的 :attribute 无效。',
     'not_regex'            => ':attribute 格式错误。',
     'numeric'              => ':attribute 必须是一个数。',
@@ -90,6 +90,7 @@ return [
     'required_without'     => '当:values不存在时,:attribute 字段是必需的。',
     'required_without_all' => '当:values均不存在时,:attribute 字段是必需的。',
     'same'                 => ':attribute 与 :other 必须匹配。',
+    'safe_url'             => '提供的链接可能不安全。',
     'size'                 => [
         'numeric' => ':attribute 必须为:size。',
         'file'    => ':attribute 必须为:size KB。',
@@ -98,6 +99,7 @@ return [
     ],
     'string'               => ':attribute 必须是字符串。',
     'timezone'             => ':attribute 必须是有效的区域。',
+    'totp'                 => '您输入的认证码无效或已过期。',
     'unique'               => ':attribute 已经被使用。',
     'url'                  => ':attribute 格式无效。',
     'uploaded'             => '无法上传文件。 服务器可能不接受此大小的文件。',
index 5cf2bd3cf01c6ccd7d1e1994049b3085a7c661e3..0fe3ecd849b41d97073be558f9f0c53a81601b5a 100644 (file)
@@ -6,43 +6,52 @@
 return [
 
     // Pages
-    'page_create'                 => '建ç«\8bäº\86頁面',
+    'page_create'                 => '已建ç«\8b頁面',
     'page_create_notification'    => '頁面已建立成功',
-    'page_update'                 => '更新了頁面',
+    'page_update'                 => '已更新頁面',
     'page_update_notification'    => '頁面已更新成功',
-    'page_delete'                 => 'å\88ªé\99¤äº\86頁面',
+    'page_delete'                 => 'å·²å\88ªé\99¤頁面',
     'page_delete_notification'    => '頁面已刪除成功',
-    'page_restore'                => '恢複了頁面',
-    'page_restore_notification'   => '頁面已恢複成功',
-    'page_move'                   => '移動了頁面',
+    'page_restore'                => '已還原頁面',
+    'page_restore_notification'   => '頁面已還原成功',
+    'page_move'                   => '已移動頁面',
 
     // Chapters
-    'chapter_create'              => '建ç«\8bäº\86章節',
+    'chapter_create'              => '已建ç«\8b章節',
     'chapter_create_notification' => '章節已建立成功',
-    'chapter_update'              => '更新了章節',
+    'chapter_update'              => '已更新章節',
     'chapter_update_notification' => '章節已建立成功',
-    'chapter_delete'              => 'å\88ªé\99¤äº\86章節',
+    'chapter_delete'              => 'å·²å\88ªé\99¤章節',
     'chapter_delete_notification' => '章節已刪除成功',
-    'chapter_move'                => '移動了章節',
+    'chapter_move'                => '已移動章節',
 
     // Books
-    'book_create'                 => '建ç«\8bäº\86å\9c\96æ\9b¸',
-    'book_create_notification'    => '圖書已建立成功',
-    'book_update'                 => '更新了圖書',
-    'book_update_notification'    => '圖書已更新成功',
-    'book_delete'                 => 'å\88ªé\99¤äº\86å\9c\96æ\9b¸',
-    'book_delete_notification'    => '圖書已刪除成功',
-    'book_sort'                   => '排序了圖書',
-    'book_sort_notification'      => '圖書已重新排序成功',
+    'book_create'                 => '已建ç«\8bæ\9b¸æ\9c¬',
+    'book_create_notification'    => '書本已建立成功',
+    'book_update'                 => '已更新書本',
+    'book_update_notification'    => '書本已更新成功',
+    'book_delete'                 => 'å·²å\88ªé\99¤æ\9b¸æ\9c¬',
+    'book_delete_notification'    => '書本已刪除成功',
+    'book_sort'                   => '已排序書本',
+    'book_sort_notification'      => '書本已重新排序成功',
 
     // Bookshelves
-    'bookshelf_create'            => '建ç«\8bäº\86書架',
+    'bookshelf_create'            => '已建ç«\8b書架',
     'bookshelf_create_notification'    => '書架已建立成功',
-    'bookshelf_update'                 => '更新了書架',
+    'bookshelf_update'                 => '已更新書架',
     'bookshelf_update_notification'    => '書架已更新成功',
-    'bookshelf_delete'                 => 'å\88ªé\99¤äº\86書架',
+    'bookshelf_delete'                 => 'å·²å\88ªé\99¤書架',
     'bookshelf_delete_notification'    => '書架已刪除成功',
 
+    // 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',
+
     // Other
     'commented_on'                => '評論',
+    'permissions_update'          => '更新權限',
 ];
index c72e5d206e7813c6cd471f3e16c9f98f7eb61a92..89fc6f55ac0f1ca7c62722df08451f16a1e55701 100644 (file)
 return [
 
     'failed' => '使用者名稱或密碼錯誤。',
-    'throttle' => '您的登入次數過多,請在:秒後重試。',
+    'throttle' => '您的登入次數過多,請在:seconds秒後重試。',
 
     // Login & Register
     'sign_up' => '註冊',
     'log_in' => '登入',
-    'log_in_with' => '以:socialDriver登入',
-    'sign_up_with' => '註冊:socialDriver',
+    'log_in_with' => '以 :socialDriver 登入',
+    'sign_up_with' => '以 :socialDriver 註冊',
     'logout' => '登出',
 
     'name' => '名稱',
     'username' => '使用者名稱',
-    'email' => 'Email位址',
+    'email' => '電子郵件',
     'password' => '密碼',
     'password_confirm' => '確認密碼',
-    'password_hint' => '必須超過7個字元',
-    'forgot_password' => '忘記密碼?',
-    'remember_me' => '記住該賬戶密碼',
-    'ldap_email_hint' => '請輸入用於此帳號的電子郵件。',
+    'password_hint' => '必須超過 7 個字元',
+    'forgot_password' => '忘記密碼',
+    'remember_me' => '記住',
+    'ldap_email_hint' => '請輸入此帳號使用的電子郵件。',
     'create_account' => '建立帳號',
-    'already_have_account' => '已經擁有賬戶?',
-    'dont_have_account' => '沒有賬戶?',
-    'social_login' => 'SNS登入',
-    'social_registration' => 'SNS註冊',
-    'social_registration_text' => '其他服務註冊/登入.',
+    'already_have_account' => '已有帳號?',
+    'dont_have_account' => '沒有帳號?',
+    'social_login' => '社群網站登入',
+    'social_registration' => '使用社群網站帳號註冊',
+    'social_registration_text' => '使用其他服務註冊及登入。',
 
-    'register_thanks' => '註冊完成!',
-    'register_confirm' => '請點選查收您的Email,並點選確認。',
-    'registrations_disabled' => '註冊目前被禁用',
-    'registration_email_domain_invalid' => '此Email域名沒有權限進入本系統',
-    'register_success' => '感謝您註冊:appName,您現在已經登入。',
+    'register_thanks' => '感謝您的註冊!',
+    'register_confirm' => '請檢查您的電子郵件,並按下確認按鈕以使用 :appName 。',
+    'registrations_disabled' => '目前已停用註冊',
+    'registration_email_domain_invalid' => '這個電子郵件網域沒有權限使用',
+    'register_success' => '感謝您註冊!您已註冊完成並可登入。',
 
 
     // Password Reset
-    'reset_password' => '重密碼',
-    'reset_password_send_instructions' => '在下方輸入您的Email位址,您將收到一封帶有密碼重置連結的郵件。',
-    '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_success' => '您的密碼已成功重。',
-    'email_reset_subject' => '重置您的:appName密碼',
-    'email_reset_text' => '您收到此電子郵件是因為我們收到了您的帳號的密碼重請求。',
-    'email_reset_not_requested' => '如果您沒有要求重密碼,則不需要採取進一步的操作。',
+    'reset_password' => '重密碼',
+    'reset_password_send_instructions' => '在下方輸入您的電子郵件,您將收到一封帶有密碼重設連結的郵件。',
+    'reset_password_send_button' => '發送重連結',
+    '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位址',
-    'email_confirm_greeting' => '感謝您加入:appName!',
-    'email_confirm_text' => '請點選下面的按鈕確認您的Email位址:',
-    'email_confirm_action' => '確認Email',
-    'email_confirm_send_error' => '需要Email驗證,但系統無法發送電子郵件,請聯繫網站管理員。',
-    'email_confirm_success' => '您的Email位址已成功驗證!',
-    'email_confirm_resent' => '驗證郵件已重新發送,請檢查收件箱。',
+    '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位址未驗證',
+    'email_not_confirmed' => '電子郵件地址未確認',
     'email_not_confirmed_text' => '您的電子郵件位址尚未確認。',
     'email_not_confirmed_click_link' => '請檢查註冊時收到的電子郵件,然後點選確認連結。',
-    'email_not_confirmed_resend' => '如果找不到電子郵件,請透過下面的表單重新發送確認Email。',
-    'email_not_confirmed_resend_button' => '重新發送確認Email',
+    'email_not_confirmed_resend' => '如果找不到電子郵件,請透過下面的表單重新發送確認電子郵件。',
+    'email_not_confirmed_resend_button' => '重新傳送確認電子郵件',
 
     // User Invite
-    'user_invite_email_subject' => '您被邀請加入:bookstack!',
-    'user_invite_email_greeting' => '我們為您在bookstack上創建了一個新賬戶。',
-    'user_invite_email_text' => '請點擊下面的按鈕設置賬戶密碼并獲取訪問權限:',
-    'user_invite_email_action' => '請設置賬戶密碼',
-    'user_invite_page_welcome' => '歡迎使用:bookstack',
-    'user_invite_page_text' => '要完善您的賬戶并獲取訪問權限,您需要設置一個密碼,該密碼將在以後訪問時用於登陸:bookstack',
-    'user_invite_page_confirm_button' => '請確定密碼',
-    'user_invite_success' => '密碼已設置,您現在可以進入:bookstack了啦'
+    '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' => '密碼已設定,您現在可以存取 :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.',
 ];
\ No newline at end of file
index c86b0940c57cd169581b5f35be8d04979f227e40..b358111fda7b034694c0529775d50035da266f3b 100644 (file)
@@ -10,21 +10,21 @@ return [
     'back' => '返回',
     'save' => '儲存',
     'continue' => '繼續',
-    'select' => '選',
-    'toggle_all' => '換全部',
+    'select' => '選',
+    'toggle_all' => '換全部',
     'more' => '更多',
 
     // Form Labels
     'name' => '名稱',
-    'description' => 'æ\91\98è¦\81',
+    'description' => 'æ\8f\8fè¿°',
     'role' => '角色',
     'cover_image' => '封面圖片',
-    'cover_image_description' => 'æ\89\80使ç\94¨å\9c\96ç\89\87大å°\8få¿\85é \88æ\98¯440x250px。',
+    'cover_image_description' => 'æ­¤å\9c\96ç\89\87大å°\8fæ\87\89ç´\84ç\82º 440x250px。',
     
     // Actions
     'actions' => '動作',
     'view' => '檢視',
-    'view_all' => '視全部',
+    'view_all' => '視全部',
     'create' => '建立',
     'update' => '更新',
     'edit' => '編輯',
@@ -33,47 +33,63 @@ return [
     'copy' => '複製',
     'reply' => '回覆',
     'delete' => '刪除',
+    'delete_confirm' => '確認刪除',
     'search' => '搜尋',
     'search_clear' => '清除搜尋',
-    'reset' => '重',
-    'remove' => '除',
+    'reset' => '重',
+    'remove' => '除',
     'add' => '新增',
-    'fullscreen' => '全屏顯示',
+    'configure' => 'Configure',
+    'fullscreen' => '全螢幕',
+    'favourite' => '最愛',
+    'unfavourite' => '取消最愛',
+    'next' => '下一頁',
+    'previous' => '上一頁',
 
     // Sort Options
-    'sort_options' => '選項分類',
+    'sort_options' => '排序選項',
     'sort_direction_toggle' => '順序方向切換',
-    'sort_ascending' => '序',
-    'sort_descending' => 'é\99\8d序',
+    'sort_ascending' => '遞增排序',
+    'sort_descending' => 'é\81\9eæ¸\9bæ\8e\92序',
     'sort_name' => '名稱',
-    'sort_created_at' => '創建日期',
+    'sort_default' => '預設',
+    'sort_created_at' => '建立日期',
     'sort_updated_at' => '更新日期',
 
     // Misc
-    'deleted_user' => '刪除使用者',
-    'no_activity' => '無活動',
-    'no_items' => '無項目',
+    'deleted_user' => 'å·²å\88ªé\99¤ä½¿ç\94¨è\80\85',
+    'no_activity' => '無活動可顯示',
+    'no_items' => '無可用項目',
     'back_to_top' => '回到頂端',
-    'toggle_details' => '顯示/隱藏詳細資訊',
-    'toggle_thumbnails' => '顯示/隱藏縮圖',
+    'skip_to_main_content' => '跳到主內容',
+    'toggle_details' => '顯示/隱藏詳細資訊',
+    'toggle_thumbnails' => '顯示/隱藏縮圖',
     'details' => '詳細資訊',
-    'grid_view' => '縮å\9c\96檢視',
-    'list_view' => '清單撿視',
+    'grid_view' => '網格檢視',
+    'list_view' => '列表檢視',
     'default' => '預設',
-    'breadcrumb' => '導覽路徑',
+    'breadcrumb' => '頁面路徑',
 
     // Header
-    'profile_menu' => '個人資料菜單',
-    'view_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' => '訊息',
+    'tab_info' => '資訊',
+    'tab_info_label' => '顯示次要訊息',
     'tab_content' => '內容',
+    'tab_content_label' => '顯示主要內容',
 
     // Email Content
-    'email_action_help' => '如果您無法點選“:actionText”按鈕,請將下面的網址複製到您的瀏覽器中打開:',
+    'email_action_help' => '如果您無法點擊 ":actionText" 按鈕,請將下方的網址複製並貼上到您的網路瀏覽器中:',
     'email_rights' => '版權所有',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => '隱私權政策',
+    'terms_of_service' => '服務條款',
 ];
index bcadecbb6bafb1788ec2f2898afec2ace7a37b89..62f515f5ef8e1b6f274e9beea77da030289bc658 100644 (file)
@@ -5,7 +5,7 @@
 return [
 
     // Image Manager
-    'image_select' => '選圖片',
+    'image_select' => '選圖片',
     'image_all' => '全部',
     'image_all_title' => '檢視所有圖片',
     'image_book_title' => '檢視上傳到此書本的圖片',
@@ -14,10 +14,10 @@ return [
     'image_uploaded' => '上傳於 :uploadedDate',
     'image_load_more' => '載入更多',
     'image_image_name' => '圖片名稱',
-    'image_delete_used' => 'æ\89\80使ç\94¨å\9c\96ç\89\87ç\9b®å\89\8d用於以下頁面。',
-    'image_delete_confirm' => '如果你想刪除它,請再次按下按鈕。',
-    'image_select_image' => '選圖片',
-    'image_dropzone' => '拖曳圖片或點選這裡上傳',
+    'image_delete_used' => 'æ­¤å\9c\96ç\89\87用於以下頁面。',
+    'image_delete_confirm_text' => '您確認想要刪除這個圖片?',
+    'image_select_image' => '選圖片',
+    'image_dropzone' => '拖曳圖片或點擊此處上傳',
     'images_deleted' => '圖片已刪除',
     'image_preview' => '圖片預覽',
     'image_upload_success' => '圖片上傳成功',
@@ -29,5 +29,6 @@ return [
     'code_editor' => '編輯程式碼',
     'code_language' => '程式語言',
     'code_content' => '程式碼內容',
+    'code_session_history' => '工作階段歷史',
     'code_save' => '儲存程式碼',
 ];
index d3280217ce3e4de33168c54c9aecf38cf382ac91..2b98bddb88d882a884afba745bcf84b20b969203 100644 (file)
@@ -8,55 +8,62 @@ return [
     // Shared
     'recently_created' => '最近建立',
     'recently_created_pages' => '最近建立的頁面',
-    'recently_updated_pages' => '最頁面',
+    'recently_updated_pages' => '最近更新的頁面',
     'recently_created_chapters' => '最近建立的章節',
     'recently_created_books' => '最近建立的書本',
-    'recently_created_shelves' => '最近建立的章節',
+    'recently_created_shelves' => '最近建立的書架',
     'recently_update' => '最近更新',
-    'recently_viewed' => '最近看過',
+    'recently_viewed' => '最近檢視',
     'recent_activity' => '近期活動',
     'create_now' => '立即建立',
-    'revisions' => '修訂歷史',
-    'meta_revision' => '版本號 #:revisionCount',
+    'revisions' => '修訂版本',
+    'meta_revision' => '修訂版本 #:revisionCount',
     'meta_created' => '建立於 :timeLength',
     'meta_created_name' => '由 :user 建立於 :timeLength',
     'meta_updated' => '更新於 :timeLength',
     'meta_updated_name' => '由 :user 更新於 :timeLength',
-    'entity_select' => '選擇項目',
+    'meta_owned_name' => ':user 所擁有',
+    'entity_select' => '選取項目',
     'images' => '圖片',
     'my_recent_drafts' => '我最近的草稿',
-    'my_recently_viewed' => '我最近看過',
-    'no_pages_viewed' => '您還沒有看過任何頁面',
-    'no_pages_recently_created' => '最近沒有頁面被建立',
+    '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_pdf' => 'PDF 檔案',
     'export_text' => '純文字檔案',
+    'export_md' => 'Markdown 檔案',
 
     // Permissions and restrictions
     'permissions' => '權限',
-    'permissions_intro' => '本設定優先權高於每個使用者角色本身所具有的權限。',
+    'permissions_intro' => '一旦啟用,這些權限將優先於任何設定的角色權限。',
     'permissions_enable' => '啟用自訂權限',
     'permissions_save' => '儲存權限',
+    'permissions_owner' => '擁有者',
 
     // Search
     'search_results' => '搜尋結果',
-    'search_total_results_found' => '共找到了:count個結果',
+    'search_total_results_found' => '找到了 :count 個結果 | 總共 :count 個結果',
     'search_clear' => '清除搜尋',
-    'search_no_pages' => '沒有找到符合的頁面',
-    'search_for_term' => '“:term”的搜尋結果',
+    'search_no_pages' => '沒有與此搜尋相符的頁面',
+    'search_for_term' => ':term 的搜尋結果',
     'search_more' => '更多結果',
-    'search_filters' => '過濾搜尋結果',
-    'search_content_type' => '種類',
+    'search_advanced' => '進階搜尋',
+    'search_terms' => '搜尋字串',
+    'search_content_type' => '內容類型',
     'search_exact_matches' => '精確符合',
     'search_tags' => '標籤搜尋',
     'search_options' => '選項',
-    'search_viewed_by_me' => '我看過的',
-    'search_not_viewed_by_me' => 'æ\88\91æ²\92ç\9c\8bé\81\8eç\9a\84',
+    'search_viewed_by_me' => '被我檢視',
+    'search_not_viewed_by_me' => 'æ\9cªè¢«æ\88\91檢è¦\96',
     'search_permissions_set' => '權限設定',
     'search_created_by_me' => '我建立的',
     'search_updated_by_me' => '我更新的',
+    'search_owned_by_me' => '我所擁有的',
     'search_date_options' => '日期選項',
     'search_updated_before' => '在此之前更新',
     'search_updated_after' => '在此之後更新',
@@ -68,10 +75,10 @@ return [
     // Shelves
     'shelf' => '書架',
     'shelves' => '書架',
-    'x_shelves' => ':架|:章節',
+    'x_shelves' => ':count 書架 | :count 章節',
     'shelves_long' => '書架',
-    'shelves_empty' => '不存在已建立的書架',
-    'shelves_create' => '建立書架',
+    'shelves_empty' => '尚未建立書架',
+    'shelves_create' => '建ç«\8bæ\96°æ\9b¸æ\9e¶',
     'shelves_popular' => '熱門書架',
     'shelves_new' => '新書架',
     'shelves_new_action' => '建立新的書架',
@@ -79,57 +86,58 @@ return [
     'shelves_new_empty' => '最近建立的書架將出現在這裡。',
     'shelves_save' => '儲存書架',
     'shelves_books' => '此書架上的書本',
-    'shelves_add_books' => '將書本添加到此書架中',
-    'shelves_drag_books' => '拖動書本到此處來將它添加至此書架中',
+    'shelves_add_books' => '新增書本至此書架',
+    'shelves_drag_books' => '將書本拖曳到此處來將其新增到此書架',
     'shelves_empty_contents' => '此書架沒有分配任何書本',
     'shelves_edit_and_assign' => '編輯書架以分配書本',
-    'shelves_edit_named' => '編輯書架「:name」',
+    'shelves_edit_named' => '編輯書架 :name',
     'shelves_edit' => '編輯書架',
     'shelves_delete' => '刪除書架',
-    'shelves_delete_named' => '刪除書架「:name」',
-    'shelves_delete_explain' => "這將刪除名為「:name」的書架。包含在其中的書本不會被刪除。",
+    'shelves_delete_named' => '刪除書架 :name',
+    'shelves_delete_explain' => "這將刪除名為「:name」的書架。其中的書本不會被刪除。",
     'shelves_delete_confirmation' => '您確定要刪除此書架嗎?',
     'shelves_permissions' => '書架權限',
     'shelves_permissions_updated' => '書架權限已更新',
-    'shelves_permissions_active' => '已啟用此書架的自訂權限',
+    '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' => '已將書架的權限複製到:count本書上',
+    'shelves_copy_permissions_explain' => '這會將此書架目前的權限設定套用到所有包含的書本上。在啟用前,請確認您已儲存任何對此書架權限的變更。',
+    'shelves_copy_permission_success' => '已將書架的權限複製到 :count 本書上',
 
     // Books
     'book' => '書本',
     'books' => '書本',
-    'x_books' => ':count本書',
+    'x_books' => ':count 本書 | :count本書',
     'books_empty' => '不存在已建立的書',
     'books_popular' => '熱門書本',
-    'books_recent' => '最近的書',
-    'books_new' => '新書',
-    'books_new_action' => '新增一本書',
+    'books_recent' => '近期書本',
+    'books_new' => '新書',
+    'books_new_action' => '新書本',
     'books_popular_empty' => '最受歡迎的書本將出現在這裡。',
     'books_new_empty' => '最近建立的書本將出現在這裡。',
-    'books_create' => '建立書本',
+    'books_create' => '建ç«\8bæ\96°æ\9b¸æ\9c¬',
     'books_delete' => '刪除書本',
-    'books_delete_named' => '刪除書本「:bookName」',
+    'books_delete_named' => '刪除書本 :bookName',
     'books_delete_explain' => '這將刪除書本「:bookName」。所有的章節和頁面都會被刪除。',
     'books_delete_confirmation' => '您確定要刪除此書本嗎?',
     'books_edit' => '編輯書本',
     'books_edit_named' => '編輯書本「:bookName」',
-    'books_form_book_name' => '書',
+    'books_form_book_name' => '書本名稱',
     'books_save' => '儲存書本',
     'books_permissions' => '書本權限',
     'books_permissions_updated' => '書本權限已更新',
     'books_empty_contents' => '本書目前沒有頁面或章節。',
-    'books_empty_create_page' => '建立頁面',
+    'books_empty_create_page' => '建立頁面',
     'books_empty_sort_current_book' => '排序目前書本',
-    'books_empty_add_chapter' => '加入章節',
-    'books_permissions_active' => '已啟用此書本的自訂權限',
-    'books_search_this' => '搜尋這本書',
+    'books_empty_add_chapter' => '新增章節',
+    'books_permissions_active' => '書本權限已啟用',
+    'books_search_this' => '搜尋此書本',
     'books_navigation' => '書本導覽',
     'books_sort' => '排序書本內容',
-    'books_sort_named' => '排序書本「:bookName」',
+    'books_sort_named' => '排序書本 :bookName',
     'books_sort_name' => '按名稱排序',
-    'books_sort_created' => 'æ\8c\89å\89µå»º時間排序',
+    'books_sort_created' => 'æ\8c\89建ç«\8b時間排序',
     'books_sort_updated' => '按更新時間排序',
     'books_sort_chapters_first' => '第一章',
     'books_sort_chapters_last' => '最後一章',
@@ -139,43 +147,43 @@ return [
     // Chapters
     'chapter' => '章節',
     'chapters' => '章節',
-    'x_chapters' => ':count個章節',
+    'x_chapters' => ':count個章節 | :count個章節',
     'chapters_popular' => '熱門章節',
     'chapters_new' => '新章節',
     'chapters_create' => '建立章節',
     'chapters_delete' => '刪除章節',
-    'chapters_delete_named' => '刪除章節「:chapterName」',
-    'chapters_delete_explain' => '這將刪除章節「:chapterName」。所有的頁面將被刪除並加入到其所在的書籍。',
+    '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_move_named' => '移動章節 :chapterName',
+    'chapter_move_success' => '章節移動到 :bookName',
     'chapters_permissions' => '章節權限',
     'chapters_empty' => '本章目前沒有頁面。',
-    'chapters_permissions_active' => '已啟用此章節的自訂權限',
+    'chapters_permissions_active' => '章節權限已啟用',
     'chapters_permissions_success' => '章節權限已更新',
-    'chapters_search_this' => '從本章節搜尋',
+    'chapters_search_this' => '搜尋此章節',
 
     // Pages
     'page' => '頁面',
     'pages' => '頁面',
-    'x_pages' => ':count個頁面',
+    '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_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_editing_named' => '正在編輯頁面 :pageName',
     'pages_edit_draft_options' => '草稿選項',
     'pages_edit_save_draft' => '儲存草稿',
     'pages_edit_draft' => '編輯頁面草稿',
@@ -184,9 +192,9 @@ return [
     '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_edit_set_changelog' => '設定變更日誌',
+    'pages_edit_enter_changelog_desc' => '輸入對您所做變動的簡易描述',
+    'pages_edit_enter_changelog' => '輸入變更日誌',
     'pages_save' => '儲存頁面',
     'pages_title' => '頁面標題',
     'pages_name' => '頁面名稱',
@@ -195,7 +203,7 @@ return [
     'pages_md_insert_image' => '插入圖片',
     'pages_md_insert_link' => '插入連結',
     'pages_md_insert_drawing' => '插入繪圖',
-    'pages_not_in_chapter' => '本頁面不在某章節中',
+    'pages_not_in_chapter' => '頁面不在章節中',
     'pages_move' => '移動頁面',
     'pages_move_success' => '頁面已移動到「:parentName」',
     'pages_copy' => '複製頁面',
@@ -203,37 +211,38 @@ return [
     'pages_copy_success' => '頁面已成功複製',
     'pages_permissions' => '頁面權限',
     'pages_permissions_success' => '頁面權限已更新',
-    'pages_revision' => '修訂',
-    'pages_revisions' => '頁面修訂',
-    'pages_revisions_named' => '“:pageName”頁面修訂',
-    'pages_revision_named' => '“:pageName”頁面修訂',
+    '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' => 'ä¿®è¨\82ç·¨è\99\9f:id',
-    'pages_revisions_numbered_changes' => 'ä¿®è¨\82ç·¨è\99\9f:id æ\9b´æ\94¹',
-    'pages_revisions_changelog' => '更新說明',
-    'pages_revisions_changes' => '說æ\98\8e',
+    'pages_revisions_numbered' => 'ä¿®è¨\82ç\89\88æ\9c¬ #:id',
+    'pages_revisions_numbered_changes' => 'ä¿®è¨\82ç\89\88æ\9c¬ #:id è®\8aæ\9b´',
+    'pages_revisions_changelog' => '變動日誌',
+    'pages_revisions_changes' => 'è®\8aå\8b\95',
     'pages_revisions_current' => '目前版本',
     'pages_revisions_preview' => '預覽',
-    'pages_revisions_restore' => '恢複',
+    'pages_revisions_restore' => '還原',
     'pages_revisions_none' => '此頁面沒有修訂',
     'pages_copy_link' => '複製連結',
-    'pages_edit_content_link' => 'ç¼\96è¾\91å\86\85容',
-    'pages_permissions_active' => '已啟用此頁面的自訂權限',
+    'pages_edit_content_link' => '編輯å\85§容',
+    'pages_permissions_active' => '頁面權限已啟用',
     'pages_initial_revision' => '初次發布',
     'pages_initial_name' => '新頁面',
-    'pages_editing_draft_notification' => '您正在編輯在 :timeDiff 內儲存的草稿.',
-    'pages_draft_edited_notification' => '此頁面已經被更新過建議您放棄此草稿。',
+    'pages_editing_draft_notification' => '您正在編輯最後儲存為 :timeDiff 的草稿。',
+    'pages_draft_edited_notification' => '此頁面已經被更新過建議您放棄此草稿。',
     'pages_draft_edit_active' => [
-        'start_a' => ':count位使用者正在編輯此頁面',
-        'start_b' => '使用者“:userName”已經開始編輯此頁面',
+        'start_a' => ':count 位使用者已經開始編輯此頁面',
+        'start_b' => '使用者 :userName 已經開始編輯此頁面',
         'time_a' => '自頁面上次更新以來',
         'time_b' => '在最近:minCount分鐘',
-        'message' => ':time,:start。注意不要覆蓋到對方的更新。',
+        'message' => ':start :time。注意不要覆寫其他人的更新!',
     ],
-    'pages_draft_discarded' => '草稿已丟棄,編輯器已更新到目前頁面內容。',
-    'pages_specific' => '定頁面',
+    'pages_draft_discarded' => '草稿已丟棄。編輯器已更新到目前頁面內容',
+    'pages_specific' => '定頁面',
     'pages_is_template' => '頁面模板',
 
     // Editor Sidebar
@@ -244,38 +253,39 @@ return [
     'tag' => '標籤',
     'tags' =>  '標籤',
     'tag_name' =>  '標籤名稱',
-    'tag_value' => '標籤值 (非必要)',
-    'tags_explain' => "加入一些標籤以更好地對您的內容進行分類。\n您可以為標籤分配一個值,以進行更深入的組織。",
-    'tags_add' => '加入另一個標籤',
+    'tag_value' => '標籤值(選擇性)',
+    'tags_explain' => "加入一些標籤以更好地對您的內容進行分類。 \n 您可以為標籤分配一個值,以進行更深入的組織。",
+    'tags_add' => '新增另一個標籤',
     'tags_remove' => '移除此標籤',
     'attachments' => '附件',
-    'attachments_explain' => '上傳一些檔案或附加連結顯示在您的網頁上。將顯示在在頁面的側邊欄。',
-    'attachments_explain_instant_save' => '這裡的更改將立即儲存。Changes here are saved instantly.',
-    'attachments_items' => '附加項目',
+    'attachments_explain' => '上傳一些檔案或附加連結顯示在您的網頁上。將顯示在在頁面的側邊欄。',
+    'attachments_explain_instant_save' => '此處的變動將會立刻儲存。',
+    'attachments_items' => '附',
     'attachments_upload' => '上傳檔案',
     'attachments_link' => '附加連結',
     'attachments_set_link' => '設定連結',
-    'attachments_delete_confirm' => '確認您想要刪除此附件後,請點選刪除。',
-    'attachments_dropzone' => '刪除檔案或點選此處加入檔案',
+    'attachments_delete' => '您確定要刪除此附件嗎?',
+    'attachments_dropzone' => '拖曳檔案或點擊此處來附加檔案',
     'attachments_no_files' => '尚未上傳檔案',
-    'attachments_explain_link' => '如果您不想上傳檔案,則可以附加連結這可以是指向其他頁面的連結,也可以是指向雲端檔案的連結。',
+    '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_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' => '本',
+    'templates_set_as_template' => '頁面為範本',
+    'templates_explain_set_as_template' => '您可以將此頁面設定為範本,以便在建立其他頁面時利用其內容。如果其他使用者對此頁面擁有檢視權限,則將可以使用此範本。',
     'templates_replace_content' => '替換頁面內容',
     'templates_append_content' => '附加到頁面內容',
     'templates_prepend_content' => '前置頁面內容',
@@ -283,32 +293,32 @@ return [
     // Profile View
     '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' => ':用戶名 沒有創建任何書架',
+    'profile_not_created_pages' => ':userName 尚未建立任何頁面',
+    'profile_not_created_chapters' => ':userName 尚未建立任何章節',
+    'profile_not_created_books' => ':userName 尚未建立任何書本',
+    'profile_not_created_shelves' => ':userName 沒有創建任何書架',
 
     // Comments
     'comment' => '評論',
     'comments' => '評論',
     'comment_add' => '新增評論',
     'comment_placeholder' => '在這裡評論',
-    'comment_count' => '{0} 無評論|[1,*] :count條評論',
+    'comment_count' => '{0} 無評論 |{1} :count 則評論 | [2,*] :count 則評論',
     'comment_save' => '儲存評論',
-    'comment_saving' => '正在儲存評論...',
-    'comment_deleting' => '正在刪除評論...',
+    'comment_saving' => '正在儲存評論……',
+    'comment_deleting' => '正在刪除評論……',
     'comment_new' => '新評論',
     'comment_created' => '評論於 :createDiff',
-    'comment_updated' => '更新於 :updateDiff (:username)',
+    'comment_updated' => '由 :username 於 :updateDiff 更新',
     'comment_deleted_success' => '評論已刪除',
     'comment_created_success' => '評論已加入',
     'comment_updated_success' => '評論已更新',
-    'comment_delete_confirm' => '你確定要刪除這條評論?',
+    'comment_delete_confirm' => '您確定要刪除這則評論?',
     'comment_in_reply_to' => '回覆 :commentId',
 
     // Revision
-    'revision_delete_confirm' => '您確定要刪除此修訂版嗎?',
-    'revision_restore_confirm' => '您確定要還原此修訂版嗎? 當前頁面內容將被替換。',
-    'revision_delete_success' => '修訂刪除',
-    'revision_cannot_delete_latest' => '無法刪除最新版本。'
-];
\ No newline at end of file
+    'revision_delete_confirm' => '您確定要刪除此修訂版本嗎?',
+    'revision_restore_confirm' => '您確定要還原此修訂版本嗎? 目前頁面內容將被替換。',
+    'revision_delete_success' => '修訂版本已刪除',
+    'revision_cannot_delete_latest' => '無法刪除最新修訂版本。'
+];
index f44f7eb8be52031867aada69174e08f35af40023..0d898552fe8b302a9c90f03ddcd7ff56376d0ccc 100644 (file)
@@ -6,62 +6,61 @@ return [
 
     // Permissions
     'permission' => '您沒有權限進入所請求的頁面。',
-    'permissionJson' => '您沒有權限執行所請求的作。',
+    'permissionJson' => '您沒有權限執行所請求的作。',
 
     // Auth
-    'error_user_exists_different_creds' => 'Email為 :email 的使用者已經存在,但具有不同的憑據。',
-    'email_already_confirmed' => 'Email已被確認,請嘗試登錄。',
-    'email_confirmation_invalid' => '此確認 Session 無效或已被使用,請重新註冊。',
-    'email_confirmation_expired' => '確認 Session 已過期,已發送新的確認電子郵件。',
-    'email_confirmation_awaiting' => '用於此賬戶的電子郵箱需要認證',
-    'ldap_fail_anonymous' => '使用匿名綁定的LDAP進入失敗。',
-    'ldap_fail_authed' => '帶有標識名稱和密碼的LDAP進入失敗。',
-    'ldap_extension_not_installed' => '未安裝LDAP PHP外掛程式',
-    '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登錄失敗,系統未提供成功的授權',
-    'social_no_action_defined' => 'æ²\92æ\9c\89å®\9a義è¡\8cç\82º',
-    'social_login_bad_response' => "在 :socialAccount 登錄時遇到錯誤:\n:error",
-    'social_account_in_use' => ':socialAccount 帳號已被使用,請嘗試透過 :socialAccount 選項登。',
-    'social_account_email_in_use' => 'Email :email 已經被使用。如果您已有帳號,則可以在個人資料設定中綁定您的 :socialAccount。',
-    'social_account_existing' => ':socialAccount已經被綁定到您的帳號。',
-    'social_account_already_used_existing' => ':socialAccount帳號已經被其他使用者使用。',
-    'social_account_not_used' => ':socialAccount帳號沒有綁定到任何使用者,請在您的個人資料設定中綁定。',
+    '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' => '使用指定的 DN 與密碼詳細資訊的 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 登入失敗,系統未提供成功的授權',
+    'social_no_action_defined' => 'æ\9cªå®\9a義å\8b\95ä½\9c',
+    'social_login_bad_response' => "在 :socialAccount 登入時遇到錯誤: \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' => 'æ\9cªæ\89¾到社交驅動程式',
-    'social_driver_not_configured' => '您的:socialAccount社交設定不正確。',
-    'invite_token_expired' => 'æ­¤é\82\80è«\8bé\8f\88æ\8e¥å·²é\81\8eæ\9c\9fï¼\8cæ\82¨å\8f¯ä»¥å\98\97試é\87\8dç½®æ\82¨ç\9a\84賬æ\88密碼。',
+    'social_driver_not_found' => 'æ\89¾ä¸\8d到社交驅動程式',
+    'social_driver_not_configured' => '您的 :socialAccount 社交設定不正確。',
+    'invite_token_expired' => 'æ­¤é\82\80è«\8bé\80£çµ\90å·²é\81\8eæ\9c\9fã\80\82æ\82¨å\8f¯ä»¥å\98\97試é\87\8d設æ\82¨ç\9a\84帳è\99\9f密碼。',
 
     // System
-    'path_not_writable' => '無法上傳到檔案路徑“:filePath”,請確保它可寫入伺服器。',
-    'cannot_get_image_from_url' => '無法從 :url 中獲取圖片',
-    'cannot_create_thumbs' => '伺服器無法建立縮圖,請檢查您是否安裝了GD PHP外掛。',
-    'server_upload_limit' => 'ä¸\8aå\82³ç\9a\84æª\94æ¡\88大å°\8fè¶\85é\81\8e伺æ\9c\8då\99¨å\85\81許ä¸\8aé\99\90。請嘗試較小的檔案。',
-    'uploaded'  => 'ä¸\8aå\82³ç\9a\84æª\94æ¡\88大å°\8fè¶\85é\81\8e伺æ\9c\8då\99¨å\85\81許ä¸\8aé\99\90。請嘗試較小的檔案。',
+    'path_not_writable' => '無法上傳到 :filePath 檔案路徑。請確定其對伺服器來說是可寫入的。',
+    'cannot_get_image_from_url' => '無法從 :url 取得圖片',
+    'cannot_create_thumbs' => '伺服器無法建立縮圖。請檢查您是否安裝了 PHP 的 GD 擴充程式。',
+    'server_upload_limit' => '伺æ\9c\8då\99¨ä¸\8då\85\81許ä¸\8aå\82³é\80\99å\80\8b大ç\9a\84æª\94æ¡\88。請嘗試較小的檔案。',
+    'uploaded'  => '伺æ\9c\8då\99¨ä¸\8då\85\81許ä¸\8aå\82³é\80\99å\80\8b大ç\9a\84æª\94æ¡\88。請嘗試較小的檔案。',
     'image_upload_error' => '上傳圖片時發生錯誤',
-    'image_upload_type_error' => '上傳圖片類型錯誤',
-    'file_upload_timeout' => 'æ\96\87ä»¶ä¸\8aå\82³å·²è¶\85時。',
+    'image_upload_type_error' => '上傳圖片類型無效',
+    'file_upload_timeout' => 'æª\94æ¡\88ä¸\8aå\82³é\80¾時。',
 
     // Attachments
-    'attachment_page_mismatch' => '附件更新期間的頁面不符合',
-    'attachment_not_found' => '沒有找到附件',
+    'attachment_not_found' => '找不到附件',
 
     // Pages
-    'page_draft_autosave_fail' => '無法儲存草稿,確保您在儲存頁面之前已經連接到互聯網',
-    'page_custom_home_deletion' => '無法刪除一個被設定為首頁的頁面',
+    'page_draft_autosave_fail' => '無法儲存草稿。請確保您在儲存此頁面前已連線至網際網路',
+    'page_custom_home_deletion' => '無法刪除被設定為首頁的頁面',
 
     // Entities
-    'entity_not_found' => 'æ\9cªæ\89¾到實體',
-    'bookshelf_not_found' => 'æ\9cªæ\89¾到書架',
-    'book_not_found' => 'æ\9cªæ\89¾å\88°å\9c\96æ\9b¸',
-    'page_not_found' => 'æ\9cªæ\89¾到頁面',
-    'chapter_not_found' => 'æ\9cªæ\89¾到章節',
-    'selected_book_not_found' => '選中的書未找到',
-    'selected_book_chapter_not_found' => 'æ\9cªæ\89¾å\88°æ\89\80é\81¸ç\9a\84å\9c\96æ\9b¸或章節',
-    'guests_cannot_save_drafts' => '訪客不能儲存草稿',
+    'entity_not_found' => 'æ\89¾ä¸\8d到實體',
+    'bookshelf_not_found' => 'æ\89¾ä¸\8d到書架',
+    'book_not_found' => 'æ\89¾ä¸\8då\88°æ\9b¸æ\9c¬',
+    'page_not_found' => 'æ\89¾ä¸\8d到頁面',
+    'chapter_not_found' => 'æ\89¾ä¸\8d到章節',
+    'selected_book_not_found' => '找不到選定的書本',
+    'selected_book_chapter_not_found' => 'æ\89¾ä¸\8då\88°é\81¸å®\9aç\9a\84æ\9b¸æ\9c¬或章節',
+    'guests_cannot_save_drafts' => '訪客無法儲存草稿',
 
     // Users
     'users_cannot_delete_only_admin' => '您不能刪除唯一的管理員帳號',
@@ -70,34 +69,37 @@ return [
     // Roles
     'role_cannot_be_edited' => '無法編輯這個角色',
     'role_system_cannot_be_deleted' => '無法刪除系統角色',
-    'role_registration_default_cannot_delete' => '無法刪除設定預設註冊的角色',
-    'role_cannot_remove_only_admin' => '該用戶是分配作為管理員職務的唯一用戶。 在嘗試在此處刪除管理員職務之前,請將其分配給其他用戶。',
+    'role_registration_default_cannot_delete' => '無法刪除設定預設註冊的角色',
+    'role_cannot_remove_only_admin' => '此使用者是唯一被指派為管理員角色的使用者。在試圖移除這裡前,請將管理員角色指派給其他使用者。',
 
     // Comments
-    'comment_list' => '取評論時發生錯誤。',
-    'cannot_add_comment_to_draft' => '您不能為草稿加入評論。',
-    'comment_add' => '加入/更新評論時發生錯誤。',
+    'comment_list' => '取評論時發生錯誤。',
+    'cannot_add_comment_to_draft' => '您無法新增評論到草稿中。',
+    'comment_add' => '新增/更新評論時發生錯誤。',
     'comment_delete' => '刪除評論時發生錯誤。',
-    'empty_comment' => '不能加入空的評論。',
+    '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.',
-    'return_home' => '返回首頁',
+    '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' => '請耐心等待網站的恢複。',
+    'app_down' => ':appName 離線中',
+    'back_soon' => '它應該很快就會重新上線。',
 
     // API errors
-    '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' => '授權令牌已過期',
+    '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 7d93b302beeda55ead01feb526401506bd199152..f4d8dc30cab98886c36b54f824948e5b1f6d45a6 100644 (file)
@@ -6,10 +6,10 @@
  */
 return [
 
-    'password' => 'å¯\86碼å¿\85é \88è\87³å°\91å\8c\85å\90«å\85­å\80\8bå­\97å\85\83並è\88\87確èª\8d相符。',
-    'user' => "使用該Email位址的使用者不存在。",
-    'token' => 'The password reset token is invalid for this email address.',
-    'sent' => '我們已經透過Email發送您的密碼重置連結。',
-    'reset' => '您的密碼已被重置。',
+    'password' => 'å¯\86碼å¿\85é \88è\87³å°\91å\85«å\80\8bå­\97å\85\83ï¼\8c並è\88\87確èª\8då¯\86碼相符。',
+    'user' => "沒有使用這個電子郵件位址的使用者。",
+    'token' => '這個電子郵件地址的密碼重設權杖無效。',
+    'sent' => '我們已經透過電子郵件發送您的密碼重設連結。',
+    'reset' => '您的密碼已被重設!',
 
 ];
index f43e4204fe386c4b5691dc92bf07fcf112d0c4aa..aa0a8799341a2db0dfeb319a0e1fc2c21f9f03ec 100644 (file)
@@ -12,172 +12,226 @@ return [
     'settings_save_success' => '設定已儲存',
 
     // App Settings
-    'app_customization' => '自定義',
+    'app_customization' => '自',
     'app_features_security' => '功能與安全',
-    'app_name' => 'App名',
-    'app_name_desc' => '此名稱將在網頁頂端和Email中顯示。',
-    'app_name_header' => '在網頁頂端顯示應用名稱?',
-    'app_public_access' => '公共訪問',
-    'app_public_access_desc' => '啟用此選項將允許未登錄的訪問者訪問BookStack實例中的內容。',
-    'app_public_access_desc_guest' => 'å\8f¯ä»¥é\80\9aé\81\8eâ\80\9c訪客â\80\9dæ\8e§å\88¶å\85¬å\85±è¨ªå\95\8fè\80\85ç\9a\84訪å\95\8f。',
-    'app_public_access_toggle' => '允許公開訪問',
-    'app_public_viewing' => '開放公開閱覽?',
-    'app_secure_images' => '啟用更高安全性的圖片上傳?',
+    'app_name' => '應用程式名稱',
+    'app_name_desc' => '此名稱會在網頁頂端與任何系統傳送的電子郵件中出現。',
+    'app_name_header' => '在網頁頂端顯示名稱',
+    'app_public_access' => '公開存取',
+    'app_public_access_desc' => '啟用此選項將會允許未登入的訪客存取您 BookStack 站台中的內容。',
+    'app_public_access_desc_guest' => 'å\8f¯ä»¥é\80\8fé\81\8eã\80\8c訪客ã\80\8d使ç\94¨è\80\85æ\8e§å\88¶å\85¬é\96\8b訪客ç\9a\84å­\98å\8f\96。',
+    'app_public_access_toggle' => '允許公開存取',
+    'app_public_viewing' => '允許公開檢視?',
+    'app_secure_images' => '更高安全性的圖片上傳',
     'app_secure_images_toggle' => '啟用更高安全性的圖片上傳',
-    'app_secure_images_desc' => 'å\87ºæ\96¼æ\95\88è\83½è\80\83é\87\8fï¼\8cæ\89\80æ\9c\89å\9c\96ç\89\87é\83½æ\98¯å\85¬é\96\8bç\9a\84ã\80\82é\80\99å\80\8bé\81¸é \85æ\9c\83å\9c¨å\9c\96ç\89\87ç\9a\84ç¶²å\9d\80å\89\8då\8a å\85¥ä¸\80å\80\8bé\9a¨æ©\9f並é\9b£ä»¥ç\8c\9c測ç\9a\84å­\97å\85\83串ï¼\8cå¾\9eè\80\8c使ç\9b´æ\8e¥é\80²å\85¥è®\8aå¾\97困難。',
+    'app_secure_images_desc' => 'å\9b ç\82ºæ\95\88è\83½å\9b ç´ ï¼\8cæ\89\80æ\9c\89å\9c\96ç\89\87é\83½æ\98¯å\85¬é\96\8bç\9a\84ã\80\82æ­¤é\81¸é \85æ\9c\83å\9c¨å\9c\96ç\89\87ç\9a\84ç¶²å\9d\80å\89\8då\8a å\85¥ä¸\80串é\9a¨æ©\9fä¸\94é\9b£ä»¥ç\8c\9c測ç\9a\84å­\97串ã\80\82確ä¿\9dæ\9cªå\95\9fç\94¨ç\9b®é\8c\84ç´¢å¼\95ï¼\8cè®\93ç\9b´æ\8e¥é\80²å\85¥è®\8aå¾\97æ\9b´困難。',
     'app_editor' => '頁面編輯器',
-    'app_editor_desc' => '選擇所有使用者將使用哪個編輯器來編輯頁面。',
-    'app_custom_html' => '自訂HTML頂端內容',
-    'app_custom_html_desc' => '此處加入的任何內容都將插入到每個頁面的<head>部分的底部,這對於覆蓋樣式或加入分析程式碼很方便。',
-    'app_custom_html_disabled_notice' => '在此設置頁面上禁用了自定義HTML標題內容,以確保可以恢復所有重大更改。',
-    'app_logo' => 'App Logo',
-    'app_logo_desc' => '這個圖片的高度應該為43px。<br>大圖片將會被縮小。',
-    'app_primary_color' => 'App主要配色',
-    'app_primary_color_desc' => '請使用十六進位數值。<br>保留空白則重置回預設配色。',
-    'app_homepage' => 'App首頁',
-    'app_homepage_desc' => '選擇要做為首頁的頁面,這將會替換預設首頁,而且這個頁面的權限設定將被忽略。',
-    'app_homepage_select' => '預設首頁選擇',
-    'app_disable_comments' => '關閉評論',
-    'app_disable_comments_toggle' => '禁用評論',
-    'app_disable_comments_desc' => '在App的所有頁面上關閉評論,已經存在的評論也不會顯示。',
+    'app_editor_desc' => '選取所有使用者將使用哪個編輯器來編輯頁面。',
+    'app_custom_html' => '自訂 HTML 標題內容',
+    'app_custom_html_desc' => '此處加入的任何內容都將插入到每個頁面的 <head> 部分的底部,這對於覆蓋樣式或加入分析程式碼很方便。',
+    'app_custom_html_disabled_notice' => '在此設定頁面上停用了自訂 HTML 標題內容,以確保任何重大變更都能被還原。',
+    'app_logo' => '應用程式圖示',
+    'app_logo_desc' => '此圖片的高度應為 43px。<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' => '連結網址',
+    'app_footer_links_add' => '新增註腳連結',
+    'app_disable_comments' => '停用評論',
+    'app_disable_comments_toggle' => '停用評論',
+    'app_disable_comments_desc' => '在應用程式的所有頁面停用評論。<br>既有的評論將不會顯示。',
 
     // Color settings
     'content_colors' => '內容顏色',
-    'content_colors_desc' => '為頁面組織層次結構中的所有元素設置顏色。 為了提高可讀性,建議選擇亮度與默認顏色相似的顏色。',
-    'bookshelf_color' => '书架顏色',
-    'book_color' => '书本颜色',
-    'chapter_color' => '章节颜色',
-    'page_color' => '页é\9d¢é¢\9c色',
+    'content_colors_desc' => '為頁面層次結構中的所有元素設定顏色。 為了提高可讀性,建議選擇亮度與預設顏色相似的顏色。',
+    'bookshelf_color' => '書架顔色',
+    'book_color' => '書本顔色',
+    'chapter_color' => '章節顔色',
+    'page_color' => 'é \81é\9d¢é¡\94色',
     'page_draft_color' => '頁面草稿顏色',
 
     // Registration Settings
-    'reg_settings' => '註冊設定',
+    'reg_settings' => '註冊',
     'reg_enable' => '啟用註冊',
     'reg_enable_toggle' => '啟用註冊',
-    'reg_enable_desc' => '啟用註冊後,用戶將可以自己註冊為應用程序用戶,註冊後,他們將獲得一個默認的單一用戶角色。',
+    'reg_enable_desc' => '啟用註冊後,使用者將可以自行註冊為應用程式的使用者。註冊後,他們將會得到一個預設的使用者角色。',
     'reg_default_role' => '註冊後的預設使用者角色',
-    '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' => '电子邮箱认证',
-    'reg_email_confirmation_toggle' => '需要電子郵件確認',
-    'reg_confirm_email_desc' => '如果使用網域名稱限制,則需要Email驗證,並且本設定將被忽略。',
-    'reg_confirm_restrict_domain' => '網域名稱限制',
-    'reg_confirm_restrict_domain_desc' => '輸入您想要限制註冊的Email域域名稱列表,用逗號隔開。在被允許與本系統連結之前,使用者會收到一封Email來確認他們的位址。<br>注意,使用者在註冊成功後可以修改他們的Email位址。',
-    'reg_confirm_restrict_domain_placeholder' => '尚未設定限制的網域',
+    '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' => '維護',
-    'maint_image_cleanup' => '清理圖',
-    'maint_image_cleanup_desc' => "掃描頁面和修訂內容以檢查哪些圖像是正在使用的以及哪些圖像是多余的。確保在運行前創建完整的數據庫和映像備份。",
-    'maint_image_cleanup_ignore_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' => 'é\83µä»¶ç\99¼é\80\81å\88°:å\9c°å\9d\80',
+    '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' => 'é\9b»å­\90é\83µä»¶å·²å\82³é\80\81å\88° :address',
     'maint_send_test_email_mail_subject' => '測試郵件',
     'maint_send_test_email_mail_greeting' => '電子郵件傳遞似乎有效!',
-    'maint_send_test_email_mail_text' => '恭喜你! 收到此電子郵件通知時,您的電子郵件設置已經認證成功。',
+    'maint_send_test_email_mail_text' => '恭喜!您收到這封電子郵件通知時,代表您的電子郵件設定已正確設定。',
+    'maint_recycle_bin_desc' => '刪除的書架、書本、章節與頁面將會被傳送到回收桶,這樣仍可以還原或永久刪除。回收桶中較舊的項目可能會在一段時間後自動移除,取決於您的系統設定。',
+    'maint_recycle_bin_open' => '開啟回收桶',
+
+    // 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_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 Address',
+    'audit_table_date' => '活動日期',
+    'audit_date_from' => '日期範圍,從',
+    'audit_date_to' => '日期範圍,到',
 
     // Role Settings
     'roles' => '角色',
     'role_user_roles' => '使用者角色',
-    'role_create' => '建立角色',
+    'role_create' => '建立角色',
     'role_create_success' => '角色建立成功',
     'role_delete' => '刪除角色',
-    'role_delete_confirm' => '這將會刪除名為 \':roleName\' 的角色.',
-    'role_delete_users_assigned' => '有:userCount位使用者屬於此角色。如果您想將此角色中的使用者遷移,請在下面選擇一個新角色。',
+    'role_delete_confirm' => '這將會刪除名為「:roleName」的角色.',
+    'role_delete_users_assigned' => '有 :userCount 位使用者屬於此角色。如果您想將此角色中的使用者遷移,請在下面選擇一個新角色。',
     'role_delete_no_migration' => "不要遷移使用者",
-    'role_delete_sure' => '您確定要刪除這個角色?',
+    'role_delete_sure' => '您確定要刪除角色?',
     'role_delete_success' => '角色刪除成功',
     'role_edit' => '編輯角色',
     'role_details' => '角色詳細資訊',
-    'role_name' => '角色名',
-    'role_desc' => '角色簡述',
-    'role_external_auth_id' => '外部身份驗證ID',
+    'role_name' => '角色名稱',
+    'role_desc' => '角色簡短說明',
+    'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
+    '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' => '管理頁面模板',
-    'role_access_api' => '存取系統API',
-    'role_manage_settings' => '管理App設定',
-    'role_asset' => '資源項目',
-    'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆蓋這裡的權限設定。',
-    'role_asset_admins' => '管理員會自動獲得對所有內容的存取權限,但這些選項可能會顯示或隱藏UI的選項。',
+    'role_manage_entity_permissions' => '管理所有書本、章節與頁面的權限',
+    'role_manage_own_entity_permissions' => '管理自己的書本、章節與頁面的權限',
+    'role_manage_page_templates' => '管理頁面範本',
+    'role_access_api' => '存取系統 API',
+    'role_manage_settings' => '管理應用程式設定',
+    'role_export_content' => 'Export content',
+    'role_asset' => '資源權限',
+    'roles_system_warning' => '請注意,有上述三項權限中的任一項的使用者都可以更改自己或系統中其他人的權限。有這些權限的角色只應分配給受信任的使用者。',
+    'role_asset_desc' => '對系統內資源的預設權限將由這裡的權限控制。若有單獨設定在書本、章節和頁面上的權限,將會覆寫這裡的權限設定。',
+    'role_asset_admins' => '管理員會自動取得對所有內容的存取權,但這些選項可能會顯示或隱藏使用者介面的選項。',
     'role_all' => '全部',
     'role_own' => '擁有',
     'role_controlled_by_asset' => '依據隸屬的資源來決定',
     'role_save' => '儲存角色',
     'role_update_success' => '角色更新成功',
-    'role_users' => '此角色的使用者',
-    'role_users_none' => '目前沒有使用者被分配到這個角色',
+    'role_users' => '屬於此角色的使用者',
+    'role_users_none' => '目前沒有使用者被分配到角色',
 
     // Users
     'users' => '使用者',
-    'user_profile' => '使用者資料',
-    'users_add_new' => '加入使用者',
+    'user_profile' => '使用者個人資料',
+    'users_add_new' => '新增使用者',
     'users_search' => '搜尋使用者',
-    'users_details' => '用戶詳情',
-    'users_details_desc' => '請設置用戶的顯示名稱和電子郵件地址, 該電子郵件地址將用於登錄該應用。',
-    'users_details_desc_no_email' => '設置一個用戶的顯示名稱,以便其他人可以認出你。',
+    'users_latest_activity' => '最新活動',
+    'users_details' => '使用者詳細資訊',
+    'users_details_desc' => '為此使用者設定顯示名稱與電子郵件地址。電子郵件地址將用於登入應用程式。',
+    'users_details_desc_no_email' => '為此使用者設定顯示名稱,這樣其他人才能認出該使用者。',
     'users_role' => '使用者角色',
-    'users_role_desc' => '選擇一個將分配給該用戶的角色。 如果將一個用戶分配給多個角色,則這些角色的權限將堆疊在一起,並且他們將獲得分配的角色的所有功能。',
-    'users_password' => '用戶密碼',
-    'users_password_desc' => '設置用於登錄賬戶的密碼, 密碼長度必須至少為6個字符。',
-    'users_send_invite_text' => '您可以選擇向該用戶發送邀請電子郵件,允許他們設置自己的密碼,或者您可以自己設置他們的密碼。',
-    'users_send_invite_option' => 'å\90\91ç\94¨æ\88¶ç\99¼é\80\81é\82\80è«\8bé\9b»å­\90é\83µä»¶',
-    '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_role_desc' => '選取要分配的此使用者的角色。若使用者被分配到多個角色,則這些角色的權限將會堆疊,使用者將會取得被分配角色的所有功能。',
+    'users_password' => '使用者密碼',
+    'users_password_desc' => '設定用於登入應用程式的密碼。密碼必須至少 6 個字元長。',
+    'users_send_invite_text' => '您可以選擇向此使用者傳送邀請電子郵件,讓他們可以設定自己的密碼,您也可以自行設定他們的密碼。',
+    'users_send_invite_option' => 'å\82³é\80\81é\82\80è«\8bé\9b»å­\90é\83µä»¶çµ¦ä½¿ç\94¨è\80\85',
+    'users_external_auth_id' => '外部身份驗證 ID',
+    'users_external_auth_id_desc' => '與外部身份驗證系統通訊時,此 ID 將用於比對使用者。',
     'users_password_warning' => '如果您想更改密碼,請填寫以下內容:',
-    'users_system_public' => '此使ç\94¨è\80\85代表é\80²å\85¥æ\82¨ç\9a\84Appç\9a\84ä»»ä½\95訪客ã\80\82å®\83ä¸\8dè\83½ç\94¨æ\96¼ç\99»å\85¥ï¼\8cè\80\8cæ\98¯自動分配。',
+    'users_system_public' => '此使ç\94¨è\80\85代表é\80 è¨ªæ\82¨ç«\99å\8f°ç\9a\84ä»»ä½\95訪客使ç\94¨è\80\85ã\80\82å\85¶ä¸\8dè\83½ç\94¨æ\96¼ç\99»å\85¥ï¼\8cè\80\8cæ\9c\83自動分配。',
     'users_delete' => '刪除使用者',
     'users_delete_named' => '刪除使用者 :userName',
-    'users_delete_warning' => '這將從系統中完全刪除名為 \':userName\' 的使用者。',
-    'users_delete_confirm' => '您確定要刪除這個使用者?',
-    'users_delete_success' => '使用者刪除成功。',
+    '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_profile' => '編輯個人資料',
     'users_edit_success' => '使用者更新成功',
     'users_avatar' => '使用者大頭照',
-    'users_avatar_desc' => '目前圖片應該為約256px的正方形。',
-    'users_preferred_language' => '語言',
-    'users_preferred_language_desc' => 'æ­¤é\81¸é \85å°\87æ\9b´æ\94¹ç\94¨æ\96¼æ\87\89ç\94¨ç\94¨æ\88¶ç\95\8cé\9d¢ç\9a\84èª\9eè¨\80ï¼\8cä½\86 é\80\99ä¸\8dæ\9c\83å½±é\9f¿ä»»ä½\95ç\94¨æ\88¶å\89µå»º的內容。',
+    'users_avatar_desc' => '選取一張代表此使用者的圖片。這應該是大約 256px 的正方形。',
+    'users_preferred_language' => '偏好語言',
+    'users_preferred_language_desc' => 'æ­¤é\81¸é \85å°\87æ\9c\83è®\8aæ\9b´ç\94¨æ\96¼æ\87\89ç\94¨ç¨\8bå¼\8f使ç\94¨è\80\85ä»\8bé\9d¢ç\9a\84èª\9eè¨\80ã\80\82ä¸\8dæ\9c\83å½±é\9f¿ä»»ä½\95使ç\94¨è\80\85建ç«\8b的內容。',
     'users_social_accounts' => '社群網站帳號',
-    'users_social_accounts_info' => '在這里,您可以連結您的其他帳號,以便方便地登入。如果您選擇解除連結,之後將不能透過此社群網站帳號登入,請設定社群網站帳號來取消本系統p的進入權限。',
+    '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' => 'å\89µå»ºä»¤ç\89\8c',
+    'users_social_disconnect' => '取消連結帳號',
+    'users_social_connected' => ':socialAccount 帳號已經成功連結到您的個人資料。',
+    'users_social_disconnected' => ':socialAccount 帳號已經成功取消連結。',
+    'users_api_tokens' => 'API 權杖',
+    'users_api_tokens_none' => '尚未為此使用者建立 API 權杖',
+    'users_api_tokens_create' => '建ç«\8bæ¬\8aæ\9d\96',
     'users_api_tokens_expires' => '過期',
-    'users_api_tokens_docs' => 'API檔案',
+    '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' => 'å\89µå»º API ä»¤ç\89\8c',
+    'user_api_token_create' => '建ç«\8b API æ¬\8aæ\9d\96',
     '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' => 'å\89µå»ºæ­¤ä»¤ç\89\8cå¾\8cï¼\8cå°\87ç«\8bå\8d³ç\94\9fæ\88\90並顯示â\80\9c令ç\89\8cIDâ\80\9då\92\8câ\80\9c令ç\89\8cå¯\86é\91°â\80\9dï¼\8c該å¯\86é\91°å\8fªæ\9c\83顯示ä¸\80次ï¼\8cå\9b æ­¤è«\8b確ä¿\9då\9c¨ç¹¼çº\8cæ\93\8dä½\9cä¹\8b前將其複製到安全的地方。',
-    'user_api_token_create_success' => 'æ\88\90å\8a\9få\89µå»ºAPI令ç\89\8c',
-    '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' => '令牌已創建:time Ago',
-    'user_api_token_updated' => '令牌已更新:timeAgo',
-    'user_api_token_delete' => '刪除令牌',
-    'user_api_token_delete_warning' => '這將從系統中完全刪除名稱為\':tokenName\'的API令牌。',
-    'user_api_token_delete_confirm' => '您確定要刪除這個API令牌嗎?',
-    'user_api_token_delete_success' => 'API令牌成功刪除',
+    'user_api_token_name_desc' => '給您的權杖易於辨識的名稱,如此未來才能提醒其預期用途。',
+    'user_api_token_expiry' => '到期日',
+    'user_api_token_expiry_desc' => '設定此權杖的到期日。在此日期後,使用此權杖發出的請求將不再起作用。若將此欄留空,將會設定在100年後過期。',
+    'user_api_token_create_secret_message' => '建ç«\8bæ­¤æ¬\8aæ\9d\96å¾\8cï¼\8cå°\87æ\9c\83ç«\8bå\8d³ç\94\9fæ\88\90並顯示ã\80\8cæ¬\8aæ\9d\96 IDã\80\8dè\88\87ã\80\8cæ¬\8aæ\9d\96å¯\86碼ã\80\8dã\80\82該å¯\86碼å°\87å\8fªæ\9c\83顯示ä¸\80次ï¼\8cå\9b æ­¤è«\8bå\9c¨ç¹¼çº\8cæ\93\8dä½\9c前將其複製到安全的地方。',
+    'user_api_token_create_success' => 'æ\88\90å\8a\9f建ç«\8b API æ¬\8aæ\9d\96',
+    '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' => '這將會從系統中完全刪除名為「:tokenName」的 API 權杖。',
+    'user_api_token_delete_confirm' => '您確定要刪除此 API 權杖嗎?',
+    'user_api_token_delete_success' => 'API 權杖已成功刪除',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
@@ -185,6 +239,9 @@ return [
     'language_select' => [
         'en' => 'English',
         'ar' => 'العربية',
+        'bg' => 'Bǎlgarski',
+        'bs' => 'Bosanski',
+        'ca' => '加泰隆尼亞語',
         'cs' => 'Česky',
         'da' => '丹麥',
         'de' => 'Deutsch (Sie)',
@@ -192,13 +249,19 @@ return [
         'es' => 'Español',
         'es_AR' => 'Español Argentina',
         'fr' => 'Français',
-        'he' => 'עברית',
+        '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',
index 046e244514260413df7a910cb24a454ecd32e323..e93c182ee4fe600f75b4d57e35401557c157036f 100644 (file)
@@ -8,77 +8,77 @@
 return [
 
     // Standard laravel validation lines
-    'accepted'             => ':attribute 需要被同意。',
-    'active_url'           => ':attribute 並不是一個有效的網址',
+    'accepted'             => '必須同意 :attribute。',
+    'active_url'           => ':attribute 並非有效的網址。',
     'after'                => ':attribute 必須是在 :date 後的日期。',
     'alpha'                => ':attribute 只能包含字母。',
-    'alpha_dash'           => ':attribute 只能包含字母、數字和橫線。',
+    '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之間。',
-        'file'    => ':attribute 必須為:min到:max KB。',
-        'string'  => ':attribute 必須在:min到:max個字元之間。',
-        'array'   => ':attribute 必須在:min到:max項之間.',
+        'numeric' => ':attribute 必須在 :min 到 :max 之間。',
+        'file'    => ':attribute 必須為 :min 到 :max KB。',
+        'string'  => ':attribute 必須在 :min 到 :max 個字元之間。',
+        'array'   => ':attribute 必須在 :min 到 :max 項之間。',
     ],
-    'boolean'              => ':attribute 字段必須為 true 或 false。',
+    'boolean'              => ':attribute 欄位必須為 true 或 false。',
     'confirmed'            => ':attribute 確認不符。',
-    'date'                 => ':attribute ä¸\8dæ\98¯ä¸\80å\80\8b有效的日期。',
-    'date_format'          => ':attribute 格式不符 :format。',
+    'date'                 => ':attribute ä¸¦é\9d\9e有效的日期。',
+    'date_format'          => ':attribute 與 :format 格式不相符。',
     'different'            => ':attribute 和 :other 必須不同。',
-    'digits'               => ':attribute 必須為:digits位數。',
-    'digits_between'       => ':attribute 必須為:min到:max位數。',
-    'email'                => ':attribute 必須是有效的電子郵件址。',
-    'ends_with' => ':attribute必須以下列之一結尾::values',
-    'filled'               => ':attribute 字段是必需的。',
+    'digits'               => ':attribute 必須為 :digits 位數。',
+    'digits_between'       => ':attribute 必須為 :min 到 :max 位數。',
+    'email'                => ':attribute 必須是有效的電子郵件址。',
+    'ends_with' => ':attribute必須以下列之一結尾::values',
+    'filled'               => ':attribute 欄位必填。',
     'gt'                   => [
-        'numeric' => ':attribute必須大於:value。',
-        'file'    => ':attribute必須大於:value千字節。',
-        'string'  => ':attribute必須大於:value字符。',
-        'array'   => ':attribute必須包含比:value多的項目。',
+        'numeric' => ':attribute 必須大於 :value。',
+        'file'    => ':attribute 必須大於 :value KB。',
+        'string'  => ':attribute 必須多於 :value 個字元。',
+        'array'   => ':attribute 必須包含多於 :value 個項目。',
     ],
     'gte'                  => [
-        'numeric' => 'The :attribute必須大於或等於:value.',
-        'file'    => 'The :attribute必須大於或等於:value千字節。',
-        'string'  => 'The :attribute必須大於或等於:value字符。',
-        'array'   => 'The :attribute必須具有:value項或更多。',
+        'numeric' => ':attribute 必須大於或等於 :value。',
+        'file'    => ':attribute 必須大於或等於 :value KB。',
+        'string'  => ':attribute 必須多於或等於 :value 個字元。',
+        'array'   => ':attribute 必須有 :value 或更多項。',
     ],
-    'exists'               => '選的 :attribute 無效。',
-    'image'                => ':attribute 必須是一個圖片。',
-    'image_extension'      => 'The :attribute必須具有有效且受支持的圖像擴展名.',
-    'in'                   => '選的 :attribute 無效。',
-    'integer'              => ':attribute 必須是一個整數。',
-    'ip'                   => ':attribute 必須是一個有效的IP位址。',
-    'ipv4'                 => 'The :attribute必須是有效的IPv4地址。',
-    'ipv6'                 => 'The :attribute必須是有效的IPv6地址。',
-    'json'                 => 'The :attribute必須是有效的JSON字符串。',
+    '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必須小於:value。',
-        'file'    => 'The :attribute必須小於:value千字節。',
-        'string'  => 'The :attribute必須小於:value字符。',
-        'array'   => 'The :attribute必須少於:value個項目。',
+        'numeric' => ':attribute 必須小於 :value。',
+        'file'    => ':attribute 必須小於 :value KB。',
+        'string'  => ':attribute 必須少於 :value 個字元。',
+        'array'   => ':attribute 必須少於 :value 個項目。',
     ],
     'lte'                  => [
-        'numeric' => 'The :attribute必須小於或等於:value。',
-        'file'    => 'The :attribute必須小於或等於:value千字節。',
-        'string'  => 'The :attribute必須小於或等於:value字符。',
-        'array'   => 'The :attribute不得超過:value個項目。',
+        'numeric' => ':attribute 必須小於或等於 :value。',
+        'file'    => ':attribute 必須小於或等於 :value KB。',
+        'string'  => ':attribute 必須少於或等於 :value 個字元。',
+        'array'   => ':attribute 不能超過 :value 個項目。',
     ],
     'max'                  => [
-        'numeric' => ':attribute 不能超過:max。',
-        'file'    => ':attribute 不能超過:max KB。',
-        'string'  => ':attribute 不能超過:max個字元。',
+        'numeric' => ':attribute 不能超過 :max。',
+        'file'    => ':attribute 不能超過 :max KB。',
+        'string'  => ':attribute 不能超過 :max 個字元。',
         'array'   => ':attribute 不能有超過:max項。',
     ],
     'mimes'                => ':attribute 必須是 :values 類型的檔案。',
     'min'                  => [
         'numeric' => ':attribute 至少為:min。',
-        'file'    => ':attribute 至少為:min KB。',
+        'file'    => ':attribute 必須至少為:min KB。',
         'string'  => ':attribute 至少為:min個字元。',
         'array'   => ':attribute 至少有:min項。',
     ],
-    'no_double_extension'  => 'The :attribute必須僅具有一個文件擴展名。',
     'not_in'               => '選中的 :attribute 無效。',
     'not_regex'            => 'The :attribute格式無效。',
     'numeric'              => ':attribute 必須是一個數。',
@@ -90,6 +90,7 @@ return [
     'required_without'     => '當:values不存在時,:attribute 字段是必需的。',
     'required_without_all' => '當:values均不存在時,:attribute 字段是必需的。',
     'same'                 => ':attribute 與 :other 必須匹配。',
+    'safe_url'             => '提供的連結可能不安全。',
     'size'                 => [
         'numeric' => ':attribute 必須為:size。',
         'file'    => ':attribute 必須為:size KB。',
@@ -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 697286a78cf5f606ed06a695c4ebb73c9cd71bfd..f9c2061547fbdf5daf598d6ca75b7973d72fabfc 100644 (file)
   &.warning:before {
     background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiNjUzMWMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEgMjFoMjJMMTIgMiAxIDIxem0xMi0zaC0ydi0yaDJ2MnptMC00aC0ydi00aDJ2NHoiLz48L3N2Zz4=");
   }
+  a {
+    color: inherit;
+    text-decoration: underline;
+  }
 }
 
 /**
   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;
+  }
+}
+
 /**
  * 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.6), 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))
   }
 }
 .sticky-sidebar {
   position: sticky;
   top: $-m;
-}
\ No newline at end of file
+  max-height: calc(100vh - #{$-m});
+  overflow-y: auto;
+}
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 f65a7c42279f6214e4b268874598b4c8dca53e32..e419ab524e63112c1c383996cc326644e4b6ab4c 100644 (file)
@@ -2,8 +2,10 @@
 
 .CodeMirror {
   /* Set height, width, borders, and global font properties here */
+  font-family: monospace;
   height: 300px;
   color: black;
+  direction: ltr;
 }
 
 /* PADDING */
@@ -11,7 +13,8 @@
 .CodeMirror-lines {
   padding: 4px 0; /* Vertical padding around content */
 }
-.CodeMirror pre {
+.CodeMirror pre.CodeMirror-line,
+.CodeMirror pre.CodeMirror-line-like {
   padding: 0 4px; /* Horizontal padding of content */
 }
 
 .cm-fat-cursor div.CodeMirror-cursors {
   z-index: 1;
 }
-
+.cm-fat-cursor-mark {
+  background-color: rgba(20, 255, 20, 0.5);
+  -webkit-animation: blink 1.06s steps(1) infinite;
+  -moz-animation: blink 1.06s steps(1) infinite;
+  animation: blink 1.06s steps(1) infinite;
+}
 .cm-animate-fat-cursor {
   width: auto;
   border: 0;
@@ -89,7 +97,7 @@
 
 .CodeMirror-rulers {
   position: absolute;
-  left: 0; right: 0; top: -50px; bottom: -20px;
+  left: 0; right: 0; top: -50px; bottom: 0;
   overflow: hidden;
 }
 .CodeMirror-ruler {
 .cm-s-default .cm-property,
 .cm-s-default .cm-operator {}
 .cm-s-default .cm-variable-2 {color: #05a;}
-.cm-s-default .cm-variable-3 {color: #085;}
+.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;}
 .cm-s-default .cm-comment {color: #a50;}
 .cm-s-default .cm-string {color: #a11;}
 .cm-s-default .cm-string-2 {color: #f50;}
 
 /* Default styles for common addons */
 
-div.CodeMirror span.CodeMirror-matchingbracket {color: #0f0;}
-div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
+div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;}
+div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;}
 .CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); }
 .CodeMirror-activeline-background {background: #e8f2ff;}
 
@@ -156,17 +164,17 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
 
 .CodeMirror-scroll {
   overflow: scroll !important; /* Things will break if this is overridden */
-  /* 30px is the magic margin used to hide the element's real scrollbars */
+  /* 50px is the magic margin used to hide the element's real scrollbars */
   /* See overflow: hidden in .CodeMirror */
-  margin-bottom: -30px; margin-right: -30px;
-  padding-bottom: 30px;
+  margin-bottom: -50px; margin-right: -50px;
+  padding-bottom: 50px;
   height: 100%;
   outline: none; /* Prevent dragging from highlighting the element */
   position: relative;
 }
 .CodeMirror-sizer {
   position: relative;
-  border-right: 30px solid transparent;
+  border-right: 50px solid transparent;
 }
 
 /* The fake, visible scrollbars. Used to force redraw during scrolling
@@ -176,6 +184,7 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
   position: absolute;
   z-index: 6;
   display: none;
+  outline: none;
 }
 .CodeMirror-vscrollbar {
   right: 0; top: 0;
@@ -204,7 +213,7 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
   height: 100%;
   display: inline-block;
   vertical-align: top;
-  margin-bottom: -30px;
+  margin-bottom: -50px;
 }
 .CodeMirror-gutter-wrapper {
   position: absolute;
@@ -229,11 +238,13 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
   cursor: text;
   min-height: 1px; /* prevents collapsing before first draw */
 }
-.CodeMirror pre {
+.CodeMirror pre.CodeMirror-line,
+.CodeMirror pre.CodeMirror-line-like {
   /* Reset some styles that the rest of the page might have set */
   -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
   border-width: 0;
   background: transparent;
+  font-family: inherit;
   font-size: inherit;
   margin: 0;
   white-space: pre;
@@ -246,12 +257,9 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
   -webkit-tap-highlight-color: transparent;
   -webkit-font-variant-ligatures: contextual;
   font-variant-ligatures: contextual;
-  &:after {
-    content: none;
-    display: none;
-  }
 }
-.CodeMirror-wrap pre {
+.CodeMirror-wrap pre.CodeMirror-line,
+.CodeMirror-wrap pre.CodeMirror-line-like {
   word-wrap: break-word;
   white-space: pre-wrap;
   word-break: normal;
@@ -266,7 +274,7 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
 .CodeMirror-linewidget {
   position: relative;
   z-index: 2;
-  overflow: auto;
+  padding: 0.1px; /* Force widget margins to stay inside of the container */
 }
 
 .CodeMirror-widget {}
@@ -321,8 +329,8 @@ div.CodeMirror-dragcursors {
 .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; }
 
 .cm-searching {
-  background: #ffa;
-  background: rgba(255, 255, 0, .4);
+  background-color: #ffa;
+  background-color: rgba(255, 255, 0, .4);
 }
 
 /* Used to force a border model for a node */
@@ -341,53 +349,6 @@ div.CodeMirror-dragcursors {
 /* Help users use markselection to safely style text background */
 span.CodeMirror-selectedtext { background: none; }
 
-/**
- * Codemirror Default theme
- */
-
-.cm-s-default .cm-header {color: blue;}
-.cm-s-default .cm-quote {color: #090;}
-.cm-negative {color: #d44;}
-.cm-positive {color: #292;}
-.cm-header, .cm-strong {font-weight: bold;}
-.cm-em {font-style: italic;}
-.cm-link {text-decoration: underline;}
-.cm-strikethrough {text-decoration: line-through;}
-
-.cm-s-default .cm-keyword {color: #708;}
-.cm-s-default .cm-atom {color: #219;}
-.cm-s-default .cm-number {color: #164;}
-.cm-s-default .cm-def {color: #00f;}
-.cm-s-default .cm-variable,
-.cm-s-default .cm-punctuation,
-.cm-s-default .cm-property,
-.cm-s-default .cm-operator {}
-.cm-s-default .cm-variable-2 {color: #05a;}
-.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;}
-.cm-s-default .cm-comment {color: #a50;}
-.cm-s-default .cm-string {color: #a11;}
-.cm-s-default .cm-string-2 {color: #f50;}
-.cm-s-default .cm-meta {color: #555;}
-.cm-s-default .cm-qualifier {color: #555;}
-.cm-s-default .cm-builtin {color: #30a;}
-.cm-s-default .cm-bracket {color: #997;}
-.cm-s-default .cm-tag {color: #170;}
-.cm-s-default .cm-attribute {color: #00c;}
-.cm-s-default .cm-hr {color: #999;}
-.cm-s-default .cm-link {color: #00c;}
-
-.cm-s-default .cm-error {color: #f00;}
-.cm-invalidchar {color: #f00;}
-
-.CodeMirror-composing { border-bottom: 2px solid; }
-
-/* Default styles for common addons */
-
-div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;}
-div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;}
-.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); }
-.CodeMirror-activeline-background {background: #e8f2ff;}
-
 /* STOP */
 
 /**
@@ -461,6 +422,9 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;}
   border: 1px solid;
   @include lightDark(border-color, #DDD, #111);
 }
+.CodeMirror pre::after {
+  display: none;
+}
 html.dark-mode .CodeMirror pre {
   background-color: transparent;
 }
index 683694d96b354b391247a4a3c9fdd9d296089b31..c51f0165922b3c274e200fa79585a2ca246dc158 100644 (file)
   fill: currentColor !important;
 }
 
+.text-white {
+  color: #fff;
+  fill: currentColor !important;
+}
+
 /*
  * Entity text colors
  */
 .bg-chapter {
   background-color: var(--color-chapter);
 }
-.bg-shelf {
+.bg-bookshelf {
   background-color: var(--color-bookshelf);
 }
-
-.bg-shelf, .bg-book {
-  @include whenDark {
-    filter: brightness(67%) saturate(80%);
-  }
-}
index c73c503b461cc6ee7d09f2a23efeb405b6ece6ee..95ba81520bc431eaac45a9b7dba22931126505a4 100644 (file)
@@ -190,18 +190,15 @@ 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);
   transition: all cubic-bezier(.4, 0, 1, 1) 160ms;
   overflow: hidden;
   &.selected {
-    //transform: scale3d(0.92, 0.92, 0.92);
-    border: 4px solid #FFF;
-    overflow: hidden;
-    border-radius: 8px;
-    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
+    transform: scale3d(0.92, 0.92, 0.92);
+    outline: currentColor 2px solid;
   }
   img {
     width: 100%;
@@ -222,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 {
@@ -231,7 +228,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   }
 }
 
-#image-manager .load-more {
+.image-manager .load-more {
   display: block;
   text-align: center;
   @include lightDark(background-color, #EEE, #444);
@@ -243,6 +240,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   font-style: italic;
 }
 
+.image-manager .loading-container {
+  text-align: center;
+}
+
 .image-manager-sidebar {
   width: 300px;
   overflow-y: auto;
@@ -250,6 +251,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   border-inline-start: 1px solid #DDD;
   @include lightDark(border-color, #ddd, #000);
   .inner {
+    min-height: auto;
     padding: $-m;
   }
   img {
@@ -291,6 +293,12 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   }
 }
 
+.image-manager .corner-button {
+  margin: 0;
+  border-radius: 0;
+  padding: $-m;
+}
+
 // Dropzone
 /*
  * The MIT License
@@ -298,7 +306,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
  */
 .dz-message {
   font-size: 1em;
-  line-height: 2.35;
+  line-height: 2.85;
   font-style: italic;
   color: #888;
   text-align: center;
@@ -601,9 +609,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
     display: inline-block;
     @include lightDark(color, #666, #999);
     cursor: pointer;
+    border-right: 1px solid rgba(0, 0, 0, 0.1);
+    border-bottom: 2px solid transparent;
     &.selected {
       border-bottom: 2px solid var(--color-primary);
     }
+    &:last-child {
+      border-right: 0;
+    }
   }
 }
 
@@ -616,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;
@@ -711,4 +724,65 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   .template-item-actions button:first-child {
     border-top: 0;
   }
+}
+
+.dropdown-search-dropdown {
+  box-shadow: $bs-med;
+  overflow: hidden;
+  min-height: 100px;
+  width: 240px;
+  display: none;
+  position: absolute;
+  z-index: 80;
+  right: -$-m;
+  @include rtl {
+    right: auto;
+    left: -$-m;
+  }
+  .dropdown-search-search .svg-icon {
+    position: absolute;
+    left: $-s;
+    @include rtl {
+      right: $-s;
+      left: auto;
+    }
+    top: 11px;
+    fill: #888;
+    pointer-events: none;
+  }
+  .dropdown-search-list {
+    max-height: 400px;
+    overflow-y: scroll;
+    text-align: start;
+  }
+  .dropdown-search-item {
+    padding: $-s $-m;
+    &:hover,&:focus {
+      background-color: #F2F2F2;
+      text-decoration: none;
+    }
+  }
+  input {
+    padding-inline-start: $-xl;
+    border-radius: 0;
+    border: 0;
+    border-bottom: 1px solid #DDD;
+  }
+}
+
+@include smaller-than($m) {
+  .dropdown-search-dropdown {
+    position: fixed;
+    right: auto;
+    left: $-m;
+  }
+  .dropdown-search-dropdown .dropdown-search-list {
+    max-height: 240px;
+  }
+}
+
+.custom-select-input {
+  max-width: 280px;
+  border: 1px solid #D4D4D4;
+  border-radius: 3px;
 }
\ No newline at end of file
diff --git a/resources/sass/_footer.scss b/resources/sass/_footer.scss
new file mode 100644 (file)
index 0000000..1c58bcc
--- /dev/null
@@ -0,0 +1,17 @@
+/**
+ * Includes the footer links.
+ */
+
+ footer {
+    flex-shrink: 0;
+    padding: 1rem 1rem 2rem 1rem;
+    text-align: center;
+  }
+  
+  footer a {
+    margin: 0 .5em;
+  }
+  
+  body.flexbox footer {
+    display: none;
+  }
\ No newline at end of file
index 32d873642f46c41d7ebc8fa33c742a7014989290..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;
-    margin-inline-end: 16px;
+    padding-inline-end: 16px;
   }
   [drawio-diagram]:hover {
     outline: 2px solid var(--color-primary);
@@ -187,7 +192,7 @@ table.form-table {
   max-width: 100%;
   td {
     overflow: hidden;
-    padding: $-xxs/2 0;
+    padding: math.div($-xxs, 2) 0;
   }
 }
 
@@ -196,6 +201,16 @@ input[type="color"], input[type="password"], select, textarea {
   @extend .input-base;
 }
 
+select {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='%23666666'><polygon points='0,0 100,0 50,50'/></svg>");
+  background-size: 12px;
+  background-position: calc(100% - 20px) 70%;
+  background-repeat: no-repeat;
+}
+
 input[type=date] {
   width: 190px;
 }
index 4c3f6c619d802db2c6fbe7f6d919a00b1280058b..1a7015078e3891f44fa3511e0b88985073ce7f96 100644 (file)
@@ -3,7 +3,7 @@
  */
 
 header .grid {
-  grid-template-columns: auto min-content auto;
+  grid-template-columns: minmax(max-content, 2fr) 1fr minmax(max-content, 2fr);
 }
 
 @include smaller-than($l) {
@@ -24,7 +24,7 @@ header {
   padding: $-xxs 0;
   @include lightDark(border-bottom-color, #DDD, #000);
   @include whenDark {
-    filter: saturate(0.6) brightness(0.8);
+    filter: saturate(0.8) brightness(0.8);
   }
   .links {
     display: inline-block;
@@ -77,9 +77,6 @@ header {
 }
 
 
-.header-search {
-  display: inline-block;
-}
 header .search-box {
   display: inline-block;
   margin-top: 10px;
@@ -194,6 +191,11 @@ header .search-box {
       @include lightDark(color, #000, #fff);
       text-decoration: none;
     }
+    &:focus {
+      @include lightDark(background-color, #eee, #333);
+      outline-color: var(--color-primary);
+      color: var(--color-primary);
+    }
   }
   header .dropdown-container {
     display: block;
@@ -219,17 +221,22 @@ header .search-box {
   z-index: 5;
   background-color: #FFF;
   border-bottom: 1px solid #DDD;
+  @include lightDark(border-bottom-color, #DDD, #333);
   box-shadow: $bs-card;
 }
 .tri-layout-mobile-tab {
   text-align: center;
   border-bottom: 3px solid #BBB;
   cursor: pointer;
+  margin: 0;
+  @include lightDark(background-color, #FFF, #222);
+  @include lightDark(border-bottom-color, #BBB, #333);
   &:first-child {
     border-inline-end: 1px solid #DDD;
+    @include lightDark(border-inline-end-color, #DDD, #000);
   }
-  &.active {
-    border-bottom-color: currentColor;
+  &[aria-selected="true"] {
+    border-bottom-color: currentColor !important;
   }
 }
 
@@ -269,10 +276,10 @@ header .search-box {
   }
 }
 
-.breadcrumb-listing {
+.dropdown-search {
   position: relative;
-  .breadcrumb-listing-toggle {
-    padding: 6px;
+  .dropdown-search-toggle {
+    padding: $-xs;
     border: 1px solid transparent;
     border-radius: 4px;
     &:hover {
@@ -284,51 +291,11 @@ header .search-box {
   }
 }
 
-.breadcrumb-listing-dropdown {
-  box-shadow: $bs-med;
-  overflow: hidden;
-  min-height: 100px;
-  width: 240px;
-  display: none;
-  position: absolute;
-  z-index: 80;
-  right: -$-m;
-  @include rtl {
-    right: auto;
-    left: -$-m;
-  }
-  .breadcrumb-listing-search .svg-icon {
-    position: absolute;
-    left: $-s;
-    @include rtl {
-      right: $-s;
-      left: auto;
-    }
-    top: 11px;
-    fill: #888;
-    pointer-events: none;
-  }
-  .breadcrumb-listing-entity-list {
-    max-height: 400px;
-    overflow-y: scroll;
-    text-align: start;
-  }
-  input {
-    padding-inline-start: $-xl;
-    border-radius: 0;
-    border: 0;
-    border-bottom: 1px solid #DDD;
-  }
-}
-
-@include smaller-than($m) {
-  .breadcrumb-listing-dropdown {
-    position: fixed;
-    right: auto;
-    left: $-m;
-  }
-  .breadcrumb-listing-dropdown .breadcrumb-listing-entity-list {
-    max-height: 240px;
+.dropdown-search-toggle.compact {
+  padding: $-xxs $-xs;
+  .avatar {
+    height: 22px;
+    width: 22px;
   }
 }
 
index 57869d6520b04aa6a90ea5405c36d8a95de0927f..1d5defa9765fea5fdb4d2fca441fcc3a71d15691 100644 (file)
@@ -25,4 +25,7 @@ body {
   line-height: 1.6;
   @include lightDark(color, #444, #AAA);
   -webkit-font-smoothing: antialiased;
-}
\ No newline at end of file
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
index 226f5ccdb8f210abf45662253cc5f16b6ad1444a..362bab7d39195672c1511bc73832e0e6a4eb1ae0 100644 (file)
   }
 }
 
+#content {
+  flex: 1 0 auto;
+}
+
 /**
  * Flexbox layout system
  */
@@ -121,9 +125,44 @@ body.flexbox {
   position: relative;
 }
 
+.flex-container-row {
+  display: flex;
+  flex-direction: row;
+  &.v-center {
+    align-items: center;
+  }
+}
+
+.flex-container-column {
+  display: flex;
+  flex-direction: column;
+}
+
+.flex-container-column.wrap, .flex-container-row.wrap {
+  flex-wrap: wrap;
+}
+
 .flex {
   min-height: 0;
   flex: 1;
+  max-width: 100%;
+  &.fit-content {
+    flex-basis: auto;
+    flex-grow: 0;
+  }
+}
+
+.justify-flex-end {
+  justify-content: flex-end;
+}
+.justify-center {
+  justify-content: center;
+}
+.justify-space-between {
+  justify-content: space-between;
+}
+.items-center {
+  align-items: center;
 }
 
 
@@ -131,22 +170,30 @@ body.flexbox {
  * Display and float utilities
  */
 .block {
-  display: block;
+  display: block !important;
   position: relative;
 }
 
 .inline {
-  display: inline;
+  display: inline !important;
 }
 
 .block.inline {
-  display: inline-block;
+  display: inline-block !important;
+}
+
+.relative {
+  position: relative;
 }
 
 .hidden {
   display: none !important;
 }
 
+.fill-height {
+  height: 100%;
+}
+
 .float {
   float: left;
   &.right {
@@ -170,6 +217,13 @@ body.flexbox {
   }
 }
 
+/**
+ * Border radiuses
+ */
+.rounded {
+  border-radius: 4px;
+}
+
 /**
  * Inline content columns
  */
@@ -217,6 +271,7 @@ body.flexbox {
   .tri-layout-middle {
     grid-area: b;
     padding-top: $-m;
+    min-width: 0;
   }
 }
 @include smaller-than($xxl) {
@@ -244,6 +299,7 @@ body.flexbox {
     min-height: 50vh;
     overflow-y: scroll;
     overflow-x: hidden;
+    height: 100%;
     scrollbar-width: none;
     -ms-overflow-style: none;
     &::-webkit-scrollbar {
index 856cfed89d631f1e2b0f08dd179152626e68d792..436c7e5333c359dd6cd1e2cb959d9013ca8a154c 100644 (file)
@@ -357,13 +357,15 @@ ul.pagination {
     border: 1px solid #CCC;
     margin-inline-start: -1px;
     user-select: none;
-    &.disabled {
-      cursor: not-allowed;
-    }
+    @include lightDark(color, #555, #eee);
+    @include lightDark(border-color, #ccc, #666);
+  }
+  li.disabled {
+    cursor: not-allowed;
   }
   li.active span {
-    @include lightDark(color, #444, #eee);
-    @include lightDark(background-color, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5));
+    @include lightDark(color, #111, #eee);
+    @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.5));
   }
 }
 
@@ -428,6 +430,9 @@ ul.pagination {
     flex: 1;
     text-align: start;
   }
+  > .content {
+    min-width: 0;
+  }
   &:not(.no-hover) {
     cursor: pointer;
   }
@@ -436,12 +441,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);
@@ -545,6 +546,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;
@@ -560,10 +572,8 @@ ul.pagination {
   right: 0;
   margin: $-m 0;
   @include lightDark(background-color, #fff, #333);
-  box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.1);
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
   border-radius: 1px;
-  border: 1px solid #EEE;
-  @include lightDark(border-color, #eee, #000);
   min-width: 180px;
   padding: $-xs 0;
   @include lightDark(color, #555, #eee);
index f41ebd0c4bf734c5392652f8aa983eae37025bf0..c78e13446129644f1ea9501c3b177435f281983e 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;
@@ -28,6 +29,7 @@ table.table {
     padding: $-s $-s;
     vertical-align: middle;
     margin: 0;
+    overflow: visible;
   }
   th {
     font-weight: bold;
index 116504199f10653a93fa941733b45498128eaea7..cbe3cd4be02b25158f261a293c9015274f25c8a6 100644 (file)
@@ -96,9 +96,6 @@ a {
   text-decoration: none;
   transition: filter ease-in-out 80ms;
   line-height: 1.6;
-  @include whenDark {
-    filter: brightness(1.3) saturate(0.7);
-  }
   &:hover {
     text-decoration: underline;
   }
@@ -115,6 +112,13 @@ a {
   }
 }
 
+a.no-link-style {
+  color: inherit;
+  &:hover {
+    text-decoration: none;
+  }
+}
+
 .blended-links a {
   color: inherit;
   svg {
@@ -133,11 +137,14 @@ p, ul, ol, pre, table, blockquote {
 hr {
   border: 0;
   height: 1px;
-  @include lightDark(background, #eaeaea, #222);
+  @include lightDark(background, #eaeaea, #555);
   margin-bottom: $-l;
   &.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;
   }
@@ -273,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;
@@ -288,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;
+  }
 }
 
 /*
@@ -355,12 +368,21 @@ li > ol, li > ul {
   overflow-wrap: break-word;
 }
 
-.limit-text {
+.text-limit-lines-1 {
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 }
 
+.text-limit-lines-2 {
+  // -webkit use here is actually standardised cross-browser:
+  // https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+
 /**
  * Grouping
  */
index 9dbecda95bf0d5a57fe40c7b2c3cfc25401f62cb..05f48b073c0385cabd655940cb924daf17ca3c25 100644 (file)
@@ -51,6 +51,9 @@
       overflow:auto;
       iframe {
         flex: 1;
+        // Force TinyMCE iframe to render on its own layer
+        // for much greater performance in Safari
+        will-change: transform;
       }
     }
   }
 }
 
 .page-content.mce-content-body {
-  padding-top: 16px;
+  padding-block-start: 1rem;
+  padding-block-end: 1rem;
   outline: none;
+  display: block;
+}
+
+.page-content.mce-content-body > :last-child {
+  margin-bottom: 3rem;
 }
 
 // Fix to prevent 'No color' option from not being clickable.
index 2ce6a674964deb9bbda64c37fbb7c961afa2d91c..6b57147eff5ff16d387453b65ffcd4abb94ee2d5 100644 (file)
@@ -4,15 +4,11 @@
 // Screen breakpoints
 $xxl: 1400px;
 $xl: 1100px;
-$ipad-width: 1028px; // Is actually 1024 but we go over to ensure functionality.
 $l: 1000px;
 $m: 880px;
 $s: 600px;
 $xs: 400px;
 $xxs: 360px;
-$screen-lg: 1200px;
-$screen-md: 992px;
-$screen-sm: 768px;
 
 // List of screen sizes
 $screen-sizes: (('xxs', $xxs), ('xs', $xs), ('s', $s), ('m', $m), ('l', $l), ('xl', $xl));
@@ -28,15 +24,14 @@ $-xs: 6px;
 $-xxs: 3px;
 
 // List of our spacing sizes
-$spacing: (('none', 0), ('xxs', $-xxs), ('xs', $-xs), ('s', $-s), ('m', $-m), ('l', $-l), ('xl', $-xl), ('xxl', $-xxl));
+$spacing: (('none', 0), ('xxs', $-xxs), ('xs', $-xs), ('s', $-s), ('m', $-m), ('l', $-l), ('xl', $-xl), ('xxl', $-xxl), ('auto', auto));
 
 // Fonts
 $text: -apple-system, BlinkMacSystemFont,
 "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell",
 "Fira Sans", "Droid Sans", "Helvetica Neue",
 sans-serif;
-$mono: "Lucida Console", "DejaVu Sans Mono", "Ubunto Mono", Monaco, monospace;
-$heading: $text;
+$mono: "Lucida Console", "DejaVu Sans Mono", "Ubuntu Mono", Monaco, monospace;
 $fs-m: 14px;
 $fs-s: 12px;
 
@@ -59,7 +54,6 @@ $warning: #cf4d03;
 
 // Text colours
 $text-dark: #444;
-$text-light: #EEE;
 
 // Shadows
 $bs-light: 0 0 4px 1px #CCC;
@@ -68,4 +62,4 @@ $bs-med: 0 1px 3px 1px rgba(76, 76, 76, 0.26);
 $bs-large: 0 1px 6px 1px rgba(22, 22, 22, 0.2);
 $bs-card: 0 1px 6px -1px rgba(0, 0, 0, 0.1);
 $bs-card-dark: 0 1px 6px -1px rgba(0, 0, 0, 0.5);
-$bs-hover: 0 2px 2px 1px rgba(0,0,0,.13);
\ No newline at end of file
+$bs-hover: 0 2px 2px 1px rgba(0,0,0,.13);
index 6d9a1a7182afb7aa734175ee4c2bdc898f32a6f7..1a8b34c5b9af119865929d1eebde78c8180d2a99 100644 (file)
@@ -1,16 +1,14 @@
+@use "sass:math";
 @import "variables";
 @import "mixins";
-@import "spacing";
 @import "html";
 @import "text";
 @import "layout";
 @import "blocks";
 @import "tables";
-@import "header";
 @import "lists";
 @import "pages";
 
-
 html, body {
   background-color: #FFF;
 }
@@ -19,6 +17,7 @@ body {
   font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
   margin: 0;
   padding: 0;
+  display: block;
 }
 
 table {
@@ -39,4 +38,25 @@ pre:after {
 }
 pre code {
   white-space: pre-wrap;
+}
+
+.page-break {
+  page-break-after: always;
+}
+@media screen {
+  .page-break {
+    border-top: 1px solid #DDD;
+  }
+}
+
+ul.contents ul li {
+  list-style: circle;
+}
+
+.chapter-hint {
+  color: #888;
+  margin-top: 32px;
+}
+.chapter-hint + h1 {
+  margin-top: 0;
 }
\ No newline at end of file
index 5cbd7f9d5e226b9c4710acaa3b5012862ea77c63..2c51bd75c69027122b0a1f395906170eebb4fa63 100644 (file)
@@ -1,3 +1,4 @@
+@use "sass:math";
 @import "variables";
 
 header {
index 8af3634693e67d9f2f9d32bc68ae617e460518df..582bf7c7569a7faa4a55b7fc1d159e31cd490db6 100644 (file)
@@ -1,3 +1,5 @@
+@use "sass:math";
+
 @import "reset";
 @import "variables";
 @import "mixins";
 @import "codemirror";
 @import "components";
 @import "header";
+@import "footer";
 @import "lists";
 @import "pages";
 
-[v-cloak] {
-  display: none; opacity: 0;
-  animation-name: none !important;
-}
-
 // Jquery Sortable Styles
 .dragged {
   position: absolute;
@@ -113,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;
@@ -138,10 +136,29 @@ $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;
   input, button {
     border-radius: 0;
+    border: 1px solid #ddd;
     @include lightDark(border-color, #ddd, #000);
     margin-inline-start: -1px;
   }
@@ -162,6 +179,9 @@ $btt-size: 40px;
     background-color: $negative;
     color: #EEE;
   }
+  svg {
+    margin: 0;
+  }
 }
 
 .entity-selector {
@@ -192,8 +212,12 @@ $btt-size: 40px;
   .entity-list-item p {
     margin-bottom: 0;
   }
+  .entity-list-item:focus {
+    outline: 2px dotted var(--color-primary);
+    outline-offset: -4px;
+  }
   .entity-list-item.selected {
-    background-color: rgba(0, 0, 0, 0.05) !important;
+    @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
   }
   .loading {
     height: 400px;
@@ -288,4 +312,15 @@ $btt-size: 40px;
       transform: rotate(180deg);
     }
   }
+}
+
+table.table .table-user-item {
+  display: grid;
+  grid-template-columns: 42px 1fr;
+  align-items: center;
+}
+table.table .table-entity-item {
+  display: grid;
+  grid-template-columns: 36px 1fr;
+  align-items: center;
 }
\ No newline at end of file
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..c1bce80
--- /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">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..ba0f85f
--- /dev/null
@@ -0,0 +1,141 @@
+<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>
\ No newline at end of file
diff --git a/resources/views/attachments/list.blade.php b/resources/views/attachments/list.blade.php
new file mode 100644 (file)
index 0000000..f0a1354
--- /dev/null
@@ -0,0 +1,10 @@
+<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
diff --git a/resources/views/attachments/manager-edit-form.blade.php b/resources/views/attachments/manager-edit-form.blade.php
new file mode 100644 (file)
index 0000000..1583744
--- /dev/null
@@ -0,0 +1,48 @@
+<div component="ajax-form"
+     option:ajax-form:url="/attachments/{{ $attachment->id }}"
+     option:ajax-form:method="put"
+     option:ajax-form:response-container=".attachment-edit-container"
+     option:ajax-form:success-message="{{ trans('entities.attachments_updated_success') }}">
+    <h5>{{ trans('entities.attachments_edit_file') }}</h5>
+
+    <div class="form-group">
+        <label for="attachment_edit_name">{{ trans('entities.attachments_edit_file_name') }}</label>
+        <input type="text" id="attachment_edit_name"
+               name="attachment_edit_name"
+               value="{{ $attachment_edit_name ?? $attachment->name ?? '' }}"
+               placeholder="{{ trans('entities.attachments_edit_file_name') }}">
+        @if($errors->has('attachment_edit_name'))
+            <div class="text-neg text-small">{{ $errors->first('attachment_edit_name') }}</div>
+        @endif
+    </div>
+
+    <div component="tabs" class="tab-container">
+        <div class="nav-tabs">
+            <button refs="tabs@toggleFile" type="button" class="tab-item {{ $attachment->external ? '' : 'selected' }}">{{ trans('entities.attachments_upload') }}</button>
+            <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('form.dropzone', [
+                'placeholder' => trans('entities.attachments_edit_drop_upload'),
+                'url' =>  url('/attachments/upload/' . $attachment->id),
+                'successMessage' => trans('entities.attachments_file_updated'),
+            ])
+        </div>
+        <div refs="tabs@contentLink" class="{{ $attachment->external ? '' : 'hidden' }}">
+            <div class="form-group">
+                <label for="attachment_edit_url">{{ trans('entities.attachments_link_url') }}</label>
+                <input type="text" id="attachment_edit_url"
+                       name="attachment_edit_url"
+                       value="{{ $attachment_edit_url ?? ($attachment->external ? $attachment->path : '')  }}"
+                       placeholder="{{ trans('entities.attachment_link') }}">
+                @if($errors->has('attachment_edit_url'))
+                    <div class="text-neg text-small">{{ $errors->first('attachment_edit_url') }}</div>
+                @endif
+            </div>
+        </div>
+    </div>
+
+    <button component="event-emit-select"
+            option:event-emit-select:name="edit-back" type="button" class="button outline">{{ trans('common.back') }}</button>
+    <button refs="ajax-form@submit" type="button" class="button">{{ trans('common.save') }}</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/attachments/manager-link-form.blade.php b/resources/views/attachments/manager-link-form.blade.php
new file mode 100644 (file)
index 0000000..b51daa4
--- /dev/null
@@ -0,0 +1,28 @@
+{{--
+@pageId
+--}}
+<div component="ajax-form"
+     option:ajax-form:url="/attachments/link"
+     option:ajax-form:method="post"
+     option:ajax-form:response-container=".link-form-container"
+     option:ajax-form:success-message="{{ trans('entities.attachments_link_attached') }}">
+    <input type="hidden" name="attachment_link_uploaded_to" value="{{ $pageId }}">
+    <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
+    <div class="form-group">
+        <label for="attachment_link_name">{{ trans('entities.attachments_link_name') }}</label>
+        <input name="attachment_link_name" id="attachment_link_name" type="text" placeholder="{{ trans('entities.attachments_link_name') }}" value="{{ $attachment_link_name ?? '' }}">
+        @if($errors->has('attachment_link_name'))
+            <div class="text-neg text-small">{{ $errors->first('attachment_link_name') }}</div>
+        @endif
+    </div>
+    <div class="form-group">
+        <label for="attachment_link_url">{{ trans('entities.attachments_link_url') }}</label>
+        <input name="attachment_link_url" id="attachment_link_url" type="text" placeholder="{{ trans('entities.attachments_link_url_hint') }}" value="{{ $attachment_link_url ?? '' }}">
+        @if($errors->has('attachment_link_url'))
+            <div class="text-neg text-small">{{ $errors->first('attachment_link_url') }}</div>
+        @endif
+    </div>
+    <button refs="ajax-form@submit"
+            type="button"
+            class="button">{{ trans('entities.attach') }}</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/attachments/manager-list.blade.php b/resources/views/attachments/manager-list.blade.php
new file mode 100644 (file)
index 0000000..b48fde9
--- /dev/null
@@ -0,0 +1,42 @@
+<div component="sortable-list" option:sortable-list:handle-selector=".handle">
+    @foreach($attachments as $attachment)
+        <div component="ajax-delete-row"
+             option:ajax-delete-row:url="{{ url('/attachments/' . $attachment->id) }}"
+             data-id="{{ $attachment->id }}"
+             data-drag-content="{{ json_encode(['text/html' => $attachment->htmlLink(), 'text/plain' => $attachment->markdownLink()]) }}"
+             class="card drag-card">
+            <div class="handle">@icon('grip')</div>
+            <div class="py-s">
+                <a href="{{ $attachment->getUrl() }}" target="_blank" rel="noopener">{{ $attachment->name }}</a>
+            </div>
+            <div class="flex-fill justify-flex-end">
+                <button component="event-emit-select"
+                        option:event-emit-select:name="insert"
+                        type="button"
+                        title="{{ trans('entities.attachments_insert_link') }}"
+                        class="drag-card-action text-center text-primary">@icon('link')                 </button>
+                <button component="event-emit-select"
+                        option:event-emit-select:name="edit"
+                        option:event-emit-select:id="{{ $attachment->id }}"
+                        type="button"
+                        title="{{ trans('common.edit') }}"
+                        class="drag-card-action text-center text-primary">@icon('edit')</button>
+                <div component="dropdown" class="flex-fill relative">
+                    <button refs="dropdown@toggle"
+                            type="button"
+                            title="{{ trans('common.delete') }}"
+                            class="drag-card-action text-center text-neg">@icon('close')</button>
+                    <div refs="dropdown@menu" class="dropdown-menu">
+                        <p class="text-neg small px-m mb-xs">{{ trans('entities.attachments_delete') }}</p>
+                        <button refs="ajax-delete-row@delete" type="button" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endforeach
+    @if (count($attachments) === 0)
+        <p class="small text-muted">
+            {{ trans('entities.attachments_no_files') }}
+        </p>
+    @endif
+</div>
\ No newline at end of file
diff --git a/resources/views/attachments/manager.blade.php b/resources/views/attachments/manager.blade.php
new file mode 100644 (file)
index 0000000..024cb58
--- /dev/null
@@ -0,0 +1,39 @@
+<div style="display: block;" toolbox-tab-content="files"
+     component="attachments"
+     option:attachments:page-id="{{ $page->id ?? 0 }}">
+
+    <h4>{{ trans('entities.attachments') }}</h4>
+    <div class="px-l files">
+
+        <div refs="attachments@listContainer">
+            <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
+
+            <div component="tabs" refs="attachments@mainTabs" class="tab-container">
+                <div class="nav-tabs">
+                    <button refs="tabs@toggleItems" type="button" class="selected tab-item">{{ trans('entities.attachments_items') }}</button>
+                    <button refs="tabs@toggleUpload" type="button" class="tab-item">{{ trans('entities.attachments_upload') }}</button>
+                    <button refs="tabs@toggleLinks" type="button" class="tab-item">{{ trans('entities.attachments_link') }}</button>
+                </div>
+                <div refs="tabs@contentItems attachments@list">
+                    @include('attachments.manager-list', ['attachments' => $page->attachments->all()])
+                </div>
+                <div refs="tabs@contentUpload" class="hidden">
+                    @include('form.dropzone', [
+                        'placeholder' => trans('entities.attachments_dropzone'),
+                        'url' =>  url('/attachments/upload?uploaded_to=' . $page->id),
+                        'successMessage' => trans('entities.attachments_file_uploaded'),
+                    ])
+                </div>
+                <div refs="tabs@contentLinks" class="hidden link-form-container">
+                    @include('attachments.manager-link-form', ['pageId' => $page->id])
+                </div>
+            </div>
+
+        </div>
+
+        <div refs="attachments@editContainer" class="hidden attachment-edit-container">
+
+        </div>
+
+    </div>
+</div>
\ No newline at end of file
index fbe62f21e9fabdbe036f0d635dde3913d04291e4..c29ed57067bd1f1b1c47f5ddc1bc9de4a3b980d7 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
index 868e0555fd5fe7a13f6cad9f0ce5142892223492..de99bb3f29feeb55babaa3f13020c474978f4a1f 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
@@ -9,13 +9,13 @@
         <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">
                 @foreach($socialDrivers as $driver => $name)
                     <div>
-                        <a id="social-login-{{$driver}}" class="button outline block svg" href="{{ url("/login/service/" . $driver) }}">
+                        <a id="social-login-{{$driver}}" class="button outline svg" href="{{ url("/login/service/" . $driver) }}">
                             @icon('auth/' . $driver)
                             <span>{{ trans('auth.log_in_with', ['socialDriver' => $name]) }}</span>
                         </a>
similarity index 97%
rename from resources/views/auth/forms/login/openid.blade.php
rename to resources/views/auth/parts/login-form-openid.blade.php
index b1ef51d1363a7c44a122c7774163868a1818484a..ba975ebf4e914e7bd76324a0f62bb456b7730536 100644 (file)
@@ -8,4 +8,4 @@
         </button>
     </div>
 
-</form>
\ No newline at end of file
+</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 34aa81d7bf991f078104f8cff5dbecca69773737..91ec0b621f12f2190edd676c31e3ff1310db7dc2 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
     <div class="container very-small">
                     </div>
                 </div>
 
-
             </form>
 
             @if(count($socialDrivers) > 0)
                 <hr class="my-l">
                 @foreach($socialDrivers as $driver => $name)
                     <div>
-                        <a id="social-register-{{$driver}}" class="button block outline svg" href="{{ url("/register/service/" . $driver) }}">
+                        <a id="social-register-{{$driver}}" class="button outline svg" href="{{ url("/register/service/" . $driver) }}">
                             @icon('auth/' . $driver)
                             <span>{{ trans('auth.sign_up_with', ['socialDriver' => $name]) }}</span>
                         </a>
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
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 e86a24e816a339e0a443bffef08bbf28b7ab6685..0b6b4a58c19108e6bcf341ea46b8c37df633610c 100644 (file)
@@ -1,38 +1,8 @@
-<!doctype html>
-<html lang="{{ config('app.lang') }}">
-<head>
-    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
-    <title>{{ $book->name }}</title>
+@extends('layouts.export')
 
-    @include('partials.export-styles', ['format' => $format])
-
-    <style>
-        .page-break {
-            page-break-after: always;
-        }
-        .chapter-hint {
-            color: #888;
-            margin-top: 32px;
-        }
-        .chapter-hint + h1 {
-            margin-top: 0;
-        }
-        ul.contents ul li {
-            list-style: circle;
-        }
-        @media screen {
-            .page-break {
-                border-top: 1px solid #DDD;
-            }
-        }
-    </style>
-    @yield('head')
-    @include('partials.custom-head')
-</head>
-<body>
-
-<div class="page-content">
+@section('title', $book->name)
 
+@section('content')
     <h1 style="font-size: 4.8em">{{$book->name}}</h1>
 
     <p>{{ $book->description }}</p>
@@ -41,9 +11,9 @@
         <ul class="contents">
             @foreach($bookChildren as $bookChild)
                 <li><a href="#{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</a></li>
-                @if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
+                @if($bookChild->isA('chapter') && count($bookChild->visible_pages) > 0)
                     <ul>
-                        @foreach($bookChild->pages as $page)
+                        @foreach($bookChild->visible_pages as $page)
                             <li><a href="#page-{{$page->id}}">{{ $page->name }}</a></li>
                         @endforeach
                     </ul>
@@ -59,8 +29,8 @@
         @if($bookChild->isA('chapter'))
             <p>{{ $bookChild->description }}</p>
 
-            @if(count($bookChild->pages) > 0)
-                @foreach($bookChild->pages as $page)
+            @if(count($bookChild->visible_pages) > 0)
+                @foreach($bookChild->visible_pages as $page)
                     <div class="page-break"></div>
                     <div class="chapter-hint">{{$bookChild->name}}</div>
                     <h1 id="page-{{$page->id}}">{{ $page->name }}</h1>
@@ -73,8 +43,4 @@
         @endif
 
     @endforeach
-
-</div>
-
-</body>
-</html>
+@endsection
\ No newline at end of file
diff --git a/resources/views/books/grid-item.blade.php b/resources/views/books/grid-item.blade.php
deleted file mode 100644 (file)
index e1d3775..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<a href="{{$book->getUrl()}}" class="grid-card"  data-entity-type="book" data-entity-id="{{$book->id}}">
-    <div class="bg-book featured-image-container-wrap">
-        <div class="featured-image-container" @if($book->cover) style="background-image: url('{{ $book->getBookCover() }}')"@endif>
-        </div>
-        @icon('book')
-    </div>
-    <div class="grid-card-content">
-        <h2>{{$book->getShortName(35)}}</h2>
-        @if(isset($book->searchSnippet))
-            <p class="text-muted">{!! $book->searchSnippet !!}</p>
-        @else
-            <p class="text-muted">{{ $book->getExcerpt(130) }}</p>
-        @endif
-    </div>
-    <div class="grid-card-footer text-muted ">
-        <p>@icon('star')<span title="{{$book->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $book->created_at->diffForHumans()]) }}</span></p>
-        <p>@icon('edit')<span title="{{ $book->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $book->updated_at->diffForHumans()]) }}</span></p>
-    </div>
-</a>
\ No newline at end of file
index f3c3ee34b1ca2e6b16f869346e936c8d9843995a..5f1bd374372b0d9ad3dfce808363b8976cecb703 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
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @if($currentUser->can('book-create-all'))
+            @if(user()->can('book-create-all'))
                 <a href="{{ url("/create-book") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.books_create') }}</span>
                 </a>
             @endif
 
-            @include('partials.view-toggle', ['view' => $view, 'type' => 'books'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
         </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 80%
rename from resources/views/books/list-item.blade.php
rename to resources/views/books/parts/list-item.blade.php
index 17cf4c71f9878117471b6afef38f3eccff615331..a3ff0971f117cd6a8d7f748718d7176ba2af1b8f 100644 (file)
@@ -5,7 +5,7 @@
     <div class="content">
         <h4 class="entity-list-item-name break-text">{{ $book->name }}</h4>
         <div class="entity-item-snippet">
-            <p class="text-muted break-text mb-s">{{ $book->getExcerpt() }}</p>
+            <p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->description }}</p>
         </div>
     </div>
 </a>
\ No newline at end of file
similarity index 84%
rename from resources/views/books/list.blade.php
rename to resources/views/books/parts/list.blade.php
index 42a2757f94e0e949d536791f6dd79f4daa5ea38a..30b0766135ccff81c87fb2d3fa7c1a5ba9d2de4a 100644 (file)
@@ -1,10 +1,9 @@
-
 <main class="content-wrap mt-m card">
     <div class="grid half v-center no-row-gap">
         <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('books.grid-item', ['book' => $book])
+                    @include('entities.grid-item', ['entity' => $book])
                 @endforeach
              </div>
         @endif
similarity index 93%
rename from resources/views/books/sort-box.blade.php
rename to resources/views/books/parts/sort-box.blade.php
index 98f0af87eeeaa711408a6c010956bb68d9c273a3..f043735bbf4c9c0df001d5f40fc565756b467550 100644 (file)
@@ -13,8 +13,8 @@
     <ul class="sortable-page-list sort-list">
 
         @foreach($bookChildren as $bookChild)
-            <li class="text-{{ $bookChild->getClassName() }}"
-                data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getClassName() }}"
+            <li class="text-{{ $bookChild->getType() }}"
+                data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getType() }}"
                 data-name="{{ $bookChild->name }}" data-created="{{ $bookChild->created_at->timestamp }}"
                 data-updated="{{ $bookChild->updated_at->timestamp }}">
                 <div class="entity-list-item">
@@ -28,7 +28,7 @@
                 </div>
                 @if($bookChild->isA('chapter'))
                     <ul>
-                        @foreach($bookChild->pages as $page)
+                        @foreach($bookChild->visible_pages as $page)
                             <li class="text-page"
                                 data-id="{{$page->id}}" data-type="page"
                                 data-name="{{ $page->name }}" data-created="{{ $page->created_at->timestamp }}"
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..25a6f69fad9ffec241720eac4b4b39d407903798 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))
 
             <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>
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 506e8db3d40f7c18b5c7ca60fefb3ca4fe0c5ff6..61286ab170a9d4fd471f2cb22f9e9aeb8e7e2a73 100644 (file)
@@ -1,30 +1,8 @@
-<!doctype html>
-<html lang="{{ config('app.lang') }}">
-<head>
-    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
-    <title>{{ $chapter->name }}</title>
+@extends('layouts.export')
 
-    @include('partials.export-styles', ['format' => $format])
-
-    <style>
-        .page-break {
-            page-break-after: always;
-        }
-        ul.contents ul li {
-            list-style: circle;
-        }
-        @media screen {
-            .page-break {
-                border-top: 1px solid #DDD;
-            }
-        }
-    </style>
-    @include('partials.custom-head')
-</head>
-<body>
-
-<div class="page-content">
+@section('title', $chapter->name)
 
+@section('content')
     <h1 style="font-size: 4.8em">{{$chapter->name}}</h1>
 
     <p>{{ $chapter->description }}</p>
@@ -42,8 +20,4 @@
         <h1 id="page-{{$page->id}}">{{ $page->name }}</h1>
         {!! $page->html !!}
     @endforeach
-
-</div>
-
-</body>
-</html>
+@endsection
\ No newline at end of file
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 66%
rename from resources/views/chapters/child-menu.blade.php
rename to resources/views/chapters/parts/child-menu.blade.php
index 6137c34e8fce357653db2583e3a21cb413f01aba..a00f0f7e1ae341c3a6638fe091c153118e7c9baf 100644 (file)
@@ -1,12 +1,12 @@
 <div class="chapter-child-menu">
     <button chapter-toggle type="button" aria-expanded="{{ $isOpen ? 'true' : 'false' }}"
             class="text-muted @if($isOpen) open @endif">
-        @icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->pages->count()) }}</span>
+        @icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->visible_pages->count()) }}</span>
     </button>
     <ul class="sub-menu inset-list @if($isOpen) open @endif" @if($isOpen) style="display: block;" @endif role="menu">
-        @foreach($bookChild->pages as $childPage)
+        @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 63%
rename from resources/views/chapters/list-item.blade.php
rename to resources/views/chapters/parts/list-item.blade.php
index 7e2e0e1c539c9dca666540a245c5cf2342ea9384..285e3489353cca255481e32241245f856527aae0 100644 (file)
@@ -1,4 +1,6 @@
-<a href="{{ $chapter->getUrl() }}" class="chapter entity-list-item @if($chapter->hasChildren()) has-children @endif" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
+{{--This view display child pages in a list if pre-loaded onto a 'visible_pages' property,--}}
+{{--To ensure that the pages have been loaded efficiently with permissions taken into account.--}}
+<a href="{{ $chapter->getUrl() }}" class="chapter entity-list-item @if($chapter->visible_pages->count() > 0) has-children @endif" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
     <span class="icon text-chapter">@icon('chapter')</span>
     <div class="content">
         <h4 class="entity-list-item-name break-text">{{ $chapter->name }}</h4>
@@ -7,16 +9,16 @@
         </div>
     </div>
 </a>
-@if ($chapter->hasChildren())
+@if ($chapter->visible_pages->count() > 0)
     <div class="chapter chapter-expansion">
         <span class="icon text-chapter">@icon('page')</span>
         <div class="content">
             <button type="button" chapter-toggle
                     aria-expanded="false"
-                    class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->pages->count()) }}</span></button>
+                    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->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..1646d4f18d0e1d33ab904eb86671dd855bbc98e9 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">
 
             <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 fc81f13ee73053e4c4e208f64cc64e9eb8183826..140d0d027d7ad1996a6795bec9cb61a9621769d9 100644 (file)
@@ -1,23 +1,23 @@
-<section page-comments page-id="{{ $page->id }}" class="comments-list" aria-label="{{ trans('entities.comments') }}">
+<section component="page-comments"
+         option:page-comments:page-id="{{ $page->id }}"
+         option:page-comments:updated-text="{{ trans('entities.comment_updated_success') }}"
+         option:page-comments:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+         option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
+         option:page-comments:count-text="{{ trans('entities.comment_count') }}"
+         class="comments-list"
+         aria-label="{{ trans('entities.comments') }}">
 
-    @exposeTranslations([
-        'entities.comment_updated_success',
-        'entities.comment_deleted_success',
-        'entities.comment_created_success',
-        'entities.comment_count',
-    ])
-
-    <div comment-count-bar class="grid half left-focus v-center no-row-gap">
+    <div refs="page-comments@commentCountBar" class="grid half left-focus v-center no-row-gap">
         <h5 comments-title>{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}</h5>
         @if (count($page->comments) === 0 && userCan('comment-create-all'))
-            <div class="text-m-right" comment-add-button-container>
+            <div class="text-m-right" refs="page-comments@addButtonContainer">
                 <button type="button" action="addComment"
                         class="button outline">{{ trans('entities.comment_add') }}</button>
             </div>
         @endif
     </div>
 
-    <div class="comment-container" comment-container>
+    <div refs="page-comments@commentContainer" class="comment-container">
         @foreach($page->comments as $comment)
             @include('comments.comment', ['comment' => $comment])
         @endforeach
@@ -27,7 +27,7 @@
         @include('comments.create')
 
         @if (count($page->comments) > 0)
-            <div class="text-right" comment-add-button-container>
+            <div refs="page-comments@addButtonContainer" class="text-right">
                 <button type="button" action="addComment"
                         class="button outline">{{ trans('entities.comment_add') }}</button>
             </div>
index 61e41a354fab3214883296238e9ee55ac7c9a130..a5a84b004dc8e20a2fbf514487f5bfa13b548e3d 100644 (file)
@@ -1,6 +1,7 @@
-<div class="comment-box" comment-box style="display:none;">
+<div class="comment-box" style="display:none;">
+
     <div class="header p-s">{{ trans('entities.comment_new') }}</div>
-    <div comment-form-reply-to class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
+    <div refs="page-comments@replyToRow" class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
         <div class="grid left-focus v-center">
             <div>
                 {!! trans('entities.comment_in_reply_to', ['commentId' => '<a href=""></a>']) !!}
@@ -10,7 +11,8 @@
             </div>
         </div>
     </div>
-    <div class="content px-s" comment-form-container>
+
+    <div refs="page-comments@formContainer" class="content px-s">
         <form novalidate>
             <div class="form-group description-input">
                         <textarea name="markdown" rows="3"
@@ -22,8 +24,9 @@
                 <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>
+
 </div>
\ No newline at end of file
similarity index 78%
rename from resources/views/partials/activity-item.blade.php
rename to resources/views/common/activity-item.blade.php
index 39fb35fe2728c4c50c2ee5392414f9dfdee0a5c8..eebfb591a4af40713816963b5c978d5a6b3ba171 100644 (file)
@@ -7,7 +7,7 @@
     @endif
 </div>
 
-<div v-pre>
+<div>
     @if($activity->user)
         <a href="{{ $activity->user->getProfileUrl() }}">{{ $activity->user->name }}</a>
     @else
 
     {{ $activity->getText() }}
 
-    @if($activity->entity)
+    @if($activity->entity && is_null($activity->entity->deleted_at))
         <a href="{{ $activity->entity->getUrl() }}">{{ $activity->entity->name }}</a>
     @endif
 
+    @if($activity->entity && !is_null($activity->entity->deleted_at))
+        "{{ $activity->entity->name }}"
+    @endif
+
     @if($activity->extra) "{{ $activity->extra }}" @endif
 
     <br>
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>
diff --git a/resources/views/common/custom-head.blade.php b/resources/views/common/custom-head.blade.php
new file mode 100644 (file)
index 0000000..6f88bd4
--- /dev/null
@@ -0,0 +1,7 @@
+@inject('headContent', 'BookStack\Theming\CustomHtmlHeadContentProvider')
+
+@if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
+<!-- Custom user content -->
+{!! $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 63%
rename from resources/views/partials/export-styles.blade.php
rename to resources/views/common/export-styles.blade.php
index 52bfda2a6fc40c6c02d745b5079261e88093c017..967dc19ec709264de9302900d967189358aa39e8 100644 (file)
@@ -6,6 +6,27 @@
 
 @if ($format === 'pdf')
     <style>
+
+        /* PDF size adjustments */
+        body {
+            font-size: 14px;
+            line-height: 1.2;
+        }
+
+        h1, h2, h3, h4, h5, h6 {
+            line-height: 1.2;
+        }
+
+        table {
+            max-width: 800px !important;
+            font-size: 0.8em;
+            width: 100% !important;
+        }
+
+        table td {
+            width: auto !important;
+        }
+
         /* Patches for CSS variable colors */
         a {
             color: {{ setting('app-color') }};
diff --git a/resources/views/common/footer.blade.php b/resources/views/common/footer.blade.php
new file mode 100644 (file)
index 0000000..dd488dc
--- /dev/null
@@ -0,0 +1,7 @@
+@if(count(setting('app-footer-links', [])) > 0)
+<footer>
+    @foreach(setting('app-footer-links', []) as $link)
+        <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 996d44d27a2f635f31a8a47d0518cbe34abe4b4b..cac585a65bb3c76f1ca22c2b1225fe450a6e817a 100644 (file)
@@ -1,4 +1,4 @@
-<header id="header" header-mobile-toggle class="primary-background">
+<header id="header" component="header-mobile-toggle" class="primary-background">
     <div class="grid mx-l">
 
         <div>
                     <span class="logo-text">{{ setting('app-name') }}</span>
                 @endif
             </a>
-            <div class="mobile-menu-toggle hide-over-l">@icon('more')</div>
+            <button type="button"
+                    refs="header-mobile-toggle@toggle"
+                    title="{{ trans('common.header_menu_expand') }}"
+                    aria-expanded="false"
+                    class="mobile-menu-toggle hide-over-l">@icon('more')</button>
         </div>
 
-        <div class="header-search hide-under-l">
+        <div class="flex-container-row justify-center hide-under-l">
             @if (hasAppAccess())
             <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
                 <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
         </div>
 
         <div class="text-right">
-            <nav class="header-links">
+            <nav refs="header-mobile-toggle@menu" class="header-links">
                 <div class="links text-center">
                     @if (hasAppAccess())
                         <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
-                        @if(userCanOnAny('view', \BookStack\Entities\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+                        @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
                             <a href="{{ url('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
                         @endif
                         <a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
@@ -50,7 +54,7 @@
                 </div>
                 @if(signedInUser())
                     <?php $currentUser = user(); ?>
-                    <div class="dropdown-container" component="dropdown">
+                    <div class="dropdown-container" component="dropdown" option:dropdown:bubble-escapes="true">
                         <span class="user-name py-s hide-under-l" refs="dropdown@toggle"
                               aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.profile_menu') }}" tabindex="0">
                             <img class="avatar" src="{{$currentUser->getAvatar(30)}}" alt="{{ $currentUser->name }}">
                         </span>
                         <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
                             <li>
-                                <a href="{{ url("/user/{$currentUser->id}") }}">@icon('user'){{ trans('common.view_profile') }}</a>
+                                <a href="{{ url('/favourites') }}">@icon('star'){{ trans('entities.my_favourites') }}</a>
                             </li>
                             <li>
-                                <a href="{{ url("/settings/users/{$currentUser->id}") }}">@icon('edit'){{ trans('common.edit_profile') }}</a>
+                                <a href="{{ $currentUser->getProfileUrl() }}">@icon('user'){{ trans('common.view_profile') }}</a>
+                            </li>
+                            <li>
+                                <a href="{{ $currentUser->getEditUrl() }}">@icon('edit'){{ trans('common.edit_profile') }}</a>
                             </li>
                             <li>
                                 @if(config('auth.method') === 'saml2')
@@ -74,7 +81,7 @@
                             </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-book.blade.php b/resources/views/common/home-book.blade.php
deleted file mode 100644 (file)
index 3dbcd28..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-@extends('tri-layout')
-
-@section('body')
-    @include('books.list', ['books' => $books, 'view' => $view])
-@stop
-
-@section('left')
-    @include('common.home-sidebar')
-@stop
-
-@section('right')
-    <div class="actions mb-xl">
-        <h5>{{ trans('common.actions') }}</h5>
-        <div class="icon-list text-primary">
-            @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'])
-        </div>
-    </div>
-@stop
\ No newline at end of file
diff --git a/resources/views/common/home-shelves.blade.php b/resources/views/common/home-shelves.blade.php
deleted file mode 100644 (file)
index fccbef2..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-@extends('tri-layout')
-
-@section('body')
-    @include('shelves.list', ['shelves' => $shelves, 'view' => $view])
-@stop
-
-@section('left')
-    @include('common.home-sidebar')
-@stop
-
-@section('right')
-    <div class="actions mb-xl">
-        <h5>{{ trans('common.actions') }}</h5>
-        <div class="icon-list text-primary">
-            @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'])
-        </div>
-    </div>
-@stop
\ No newline at end of file
diff --git a/resources/views/common/home-sidebar.blade.php b/resources/views/common/home-sidebar.blade.php
deleted file mode 100644 (file)
index 12adda6..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.' . ($signedIn ? 'my_recently_viewed' : 'books_recent')) }}</h5>
-    @include('partials.entity-list', [
-        'entities' => $recents,
-        'style' => 'compact',
-        'emptyText' => $signedIn ? 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..b63573d
--- /dev/null
@@ -0,0 +1 @@
+<a class="px-m py-s skip-to-content-link" href="#main-content">{{ trans('common.skip_to_main_content') }}</a>
\ No newline at end of file
diff --git a/resources/views/components/entity-selector.blade.php b/resources/views/components/entity-selector.blade.php
deleted file mode 100644 (file)
index cb41950..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<div class="form-group entity-selector-container">
-    <div entity-selector class="entity-selector {{$selectorSize ?? ''}}" entity-types="{{ $entityTypes ?? 'book,chapter,page' }}" entity-permission="{{ $entityPermission ?? 'view' }}">
-        <input type="hidden" entity-selector-input name="{{$name}}" value="">
-        <input type="text" placeholder="{{ trans('common.search') }}" entity-selector-search>
-        <div class="text-center loading" entity-selector-loading>@include('partials.loading-icon')</div>
-        <div entity-selector-results></div>
-        @if($showAdd ?? false)
-            <div class="entity-selector-add">
-                <button entity-selector-add-button type="button"
-                        class="button outline">@icon('add'){{ trans('common.add') }}</button>
-            </div>
-        @endif
-    </div>
-</div>
\ No newline at end of file
diff --git a/resources/views/components/image-manager.blade.php b/resources/views/components/image-manager.blade.php
deleted file mode 100644 (file)
index 5e2de7b..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-<div id="image-manager" image-type="{{ $imageType }}" uploaded-to="{{ $uploaded_to ?? 0 }}">
-
-    @exposeTranslations([
-        'components.image_delete_success',
-        'components.image_upload_success',
-        'errors.server_upload_limit',
-        'components.image_upload_remove',
-        'components.file_upload_timeout',
-    ])
-
-    <div component="popup" class="popup-background" v-cloak @click="hide">
-        <div class="popup-body" tabindex="-1" @click.stop>
-
-            <div class="popup-header primary-background">
-                <div class="popup-title">{{ trans('components.image_select') }}</div>
-                <button class="popup-header-close" @click="hide()">x</button>
-            </div>
-
-            <div class="flex-fill image-manager-body">
-
-                <div class="image-manager-content">
-                    <div v-if="imageType === 'gallery' || imageType === 'drawio'" class="image-manager-header primary-background-light nav-tabs grid third">
-                        <div class="tab-item" title="{{ trans('components.image_all_title') }}" :class="{selected: !filter}" @click="setFilterType(null)">@icon('images') {{ trans('components.image_all') }}</div>
-                        <div class="tab-item" title="{{ trans('components.image_book_title') }}" :class="{selected: (filter=='book')}" @click="setFilterType('book')">@icon('book', ['class' => 'text-book svg-icon']) {{ trans('entities.book') }}</div>
-                        <div class="tab-item" title="{{ trans('components.image_page_title') }}" :class="{selected: (filter=='page')}" @click="setFilterType('page')">@icon('page', ['class' => 'text-page svg-icon']) {{ trans('entities.page') }}</div>
-                    </div>
-                    <div>
-                        <form @submit.prevent="searchImages" class="contained-search-box">
-                            <input placeholder="{{ trans('components.image_search_hint') }}" v-model="searchTerm" type="text">
-                            <button :class="{active: searching}" title="{{ trans('common.search_clear') }}" type="button" @click="cancelSearch()" class="text-button cancel">@icon('close')</button>
-                            <button title="{{ trans('common.search') }}" class="text-button">@icon('search')</button>
-                        </form>
-                    </div>
-                    <div class="image-manager-list">
-                        <div v-if="images.length > 0" v-for="(image, idx) in images">
-                            <div class="image anim fadeIn" :style="{animationDelay: (idx > 26) ? '160ms' : ((idx * 25) + 'ms')}"
-                                 :class="{selected: (image==selectedImage)}" @click="imageSelect(image)">
-                                <img :src="image.thumbs.gallery" :alt="image.title" :title="image.name">
-                                <div class="image-meta">
-                                    <span class="name" v-text="image.name"></span>
-                                    <span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => "{{ getDate(image.created_at) }" . "}"]) }}</span>
-                                </div>
-                            </div>
-                        </div>
-                        <div class="load-more" v-show="hasMore" @click="fetchData">{{ trans('components.image_load_more') }}</div>
-                    </div>
-                </div>
-
-                <div class="image-manager-sidebar">
-
-                    <dropzone v-if="imageType !== 'drawio'" ref="dropzone" placeholder="{{ trans('components.image_dropzone') }}" :upload-url="uploadUrl" :uploaded-to="uploadedTo" @success="uploadSuccess"></dropzone>
-
-                    <div class="inner">
-
-                        <div class="image-manager-details anim fadeIn" v-if="selectedImage">
-
-                            <form @submit.prevent="saveImageDetails">
-                                <div class="image-manager-viewer">
-                                    <a :href="selectedImage.url" target="_blank" style="display: block;">
-                                        <img :src="selectedImage.thumbs.display" :alt="selectedImage.name"
-                                             :title="selectedImage.name">
-                                    </a>
-                                </div>
-                                <div class="form-group">
-                                    <label for="name">{{ trans('components.image_image_name') }}</label>
-                                    <input id="name" class="input-base" name="name" v-model="selectedImage.name">
-                                </div>
-                            </form>
-
-                            <div class="clearfix">
-                                <div class="float left">
-                                    <button type="button" class="button icon outline" @click="deleteImage">@icon('delete')</button>
-
-                                </div>
-                                <button class="button anim fadeIn float right" v-show="selectedImage" @click="callbackAndHide(selectedImage)">
-                                    {{ trans('components.image_select_image') }}
-                                </button>
-                                <div class="clearfix"></div>
-                                <div v-show="dependantPages">
-                                    <p class="text-neg text-small">
-                                        {{ trans('components.image_delete_used') }}
-                                    </p>
-                                    <ul class="text-neg">
-                                        <li v-for="page in dependantPages">
-                                            <a :href="page.url" target="_blank" class="text-neg" v-text="page.name"></a>
-                                        </li>
-                                    </ul>
-                                </div>
-                                <div v-show="deleteConfirm" class="text-neg text-small">
-                                    {{ trans('components.image_delete_confirm') }}
-                                </div>
-                            </div>
-
-                        </div>
-
-
-
-                    </div>
-                </div>
-
-            </div>
-
-        </div>
-    </div>
-</div>
\ 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
similarity index 58%
rename from resources/views/partials/book-tree.blade.php
rename to resources/views/entities/book-tree.blade.php
index c288e63674ff38b456a4b13ed530746510f11e29..ce016143a30bbc645f2784db5e62b89fcac2f6d0 100644 (file)
@@ -1,22 +1,25 @@
-<nav id="book-tree" class="book-tree mb-xl" v-pre aria-label="{{ trans('entities.books_navigation') }}">
+<nav id="book-tree"
+     class="book-tree mb-xl"
+     aria-label="{{ trans('entities.books_navigation') }}">
+
     <h5>{{ trans('entities.books_navigation') }}</h5>
 
     <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->getClassName() }} {{ $bookChild->getClassName() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
-                @include('partials.entity-list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
+            <li class="list-item-{{ $bookChild->getType() }} {{ $bookChild->getType() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
+                @include('entities.list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
 
-                @if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
+                @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)
diff --git a/resources/views/entities/breadcrumb-listing.blade.php b/resources/views/entities/breadcrumb-listing.blade.php
new file mode 100644 (file)
index 0000000..929f56e
--- /dev/null
@@ -0,0 +1,23 @@
+<div class="dropdown-search" components="dropdown dropdown-search"
+     option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}"
+     option:dropdown-search:local-search-selector=".entity-list-item"
+>
+    <div class="dropdown-search-toggle" refs="dropdown@toggle"
+         aria-haspopup="true" aria-expanded="false" tabindex="0">
+        <div class="separator">@icon('chevron-right')</div>
+    </div>
+    <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu">
+        <div class="dropdown-search-search">
+            @icon('search')
+            <input refs="dropdown-search@searchInput"
+                   aria-label="{{ trans('common.search') }}"
+                   autocomplete="off"
+                   placeholder="{{ trans('common.search') }}"
+                   type="text">
+        </div>
+        <div refs="dropdown-search@loading">
+            @include('common.loading-icon')
+        </div>
+        <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m"></div>
+    </div>
+</div>
\ No newline at end of file
similarity index 92%
rename from resources/views/partials/breadcrumbs.blade.php
rename to resources/views/entities/breadcrumbs.blade.php
index 58ccd51257e0338686e746762781d36b318b5d70..d078d987322a255726ef3e4052e12bf6a0b32770 100644 (file)
@@ -2,7 +2,7 @@
     <?php $breadcrumbCount = 0; ?>
 
     {{-- Show top level books item --}}
-    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Book)
+    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Models\Book)
         <a href="{{  url('/books')  }}" class="text-book icon-list-item outline-hover">
             <span>@icon('books')</span>
             <span>{{ trans('entities.books') }}</span>
@@ -11,7 +11,7 @@
     @endif
 
     {{-- Show top level shelves item --}}
-    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Bookshelf)
+    @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof  \BookStack\Entities\Models\Bookshelf)
         <a href="{{  url('/shelves')  }}" class="text-bookshelf icon-list-item outline-hover">
             <span>@icon('bookshelf')</span>
             <span>{{ trans('entities.shelves') }}</span>
@@ -20,7 +20,7 @@
     @endif
 
     @foreach($crumbs as $key => $crumb)
-        <?php $isEntity = ($crumb instanceof \BookStack\Entities\Entity); ?>
+        <?php $isEntity = ($crumb instanceof \BookStack\Entities\Models\Entity); ?>
 
         @if (is_null($crumb))
             <?php continue; ?>
@@ -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>
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>
diff --git a/resources/views/entities/export-meta.blade.php b/resources/views/entities/export-meta.blade.php
new file mode 100644 (file)
index 0000000..02a39e7
--- /dev/null
@@ -0,0 +1,16 @@
+<div class="entity-meta">
+    @if ($entity->isA('page'))
+        @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
+    @endif
+
+    @icon('star'){!! trans('entities.meta_created' . ($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->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
diff --git a/resources/views/entities/grid-item.blade.php b/resources/views/entities/grid-item.blade.php
new file mode 100644 (file)
index 0000000..ee31b53
--- /dev/null
@@ -0,0 +1,16 @@
+<a href="{{ $entity->getUrl() }}" class="grid-card"
+   data-entity-type="{{ $entity->getType() }}" data-entity-id="{{ $entity->id }}">
+    <div class="bg-{{ $entity->getType() }} featured-image-container-wrap">
+        <div class="featured-image-container" @if($entity->cover) style="background-image: url('{{ $entity->getBookCover() }}')"@endif>
+        </div>
+        @icon($entity->getType())
+    </div>
+    <div class="grid-card-content">
+        <h2 class="text-limit-lines-2">{{ $entity->name }}</h2>
+        <p class="text-muted">{{ $entity->getExcerpt(130) }}</p>
+    </div>
+    <div class="grid-card-footer text-muted ">
+        <p>@icon('star')<span title="{{ $entity->created_at->toDayDateTimeString() }}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span></p>
+        <p>@icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span></p>
+    </div>
+</a>
\ 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 64%
rename from resources/views/partials/entity-list-item.blade.php
rename to resources/views/entities/list-item.blade.php
index d42b1967fcd230a83537cbe76ef6caae369ff184..8b5eb20b04f81e7d5e8e89b6736b63838c1a49be 100644 (file)
@@ -1,4 +1,5 @@
-@component('partials.entity-list-item-basic', ['entity' => $entity])
+@component('entities.list-item-basic', ['entity' => $entity])
+
 <div class="entity-item-snippet">
 
     @if($showPath ?? false)
 
     <p class="text-muted break-text">{{ $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
+
 @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
diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php
new file mode 100644 (file)
index 0000000..298cc7c
--- /dev/null
@@ -0,0 +1,50 @@
+<div class="entity-meta">
+    @if($entity->isA('revision'))
+        <div>
+            @icon('history'){{ trans('entities.pages_revision') }}
+            {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
+        </div>
+    @endif
+
+    @if ($entity->isA('page'))
+        <div>
+            @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif
+            @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
+            @if (userCan('page-update', $entity))</a>@endif
+        </div>
+    @endif
+
+    @if ($entity->ownedBy && $entity->owned_by !== $entity->created_by)
+        <div>
+            @icon('user'){!! trans('entities.meta_owned_name', [
+            'user' => "<a href='{$entity->ownedBy->getProfileUrl()}'>".e($entity->ownedBy->name). "</a>"
+        ]) !!}
+        </div>
+    @endif
+
+    @if ($entity->createdBy)
+        <div>
+            @icon('star'){!! trans('entities.meta_created_name', [
+            'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
+            'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
+            ]) !!}
+        </div>
+    @else
+        <div>
+            @icon('star')<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
+        </div>
+    @endif
+
+    @if ($entity->updatedBy)
+        <div>
+            @icon('edit'){!! trans('entities.meta_updated_name', [
+                'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
+                'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
+            ]) !!}
+        </div>
+    @elseif (!$entity->isA('revision'))
+        <div>
+            @icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
+        </div>
+    @endif
+</div>
\ No newline at end of file
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>
diff --git a/resources/views/entities/selector.blade.php b/resources/views/entities/selector.blade.php
new file mode 100644 (file)
index 0000000..687392d
--- /dev/null
@@ -0,0 +1,17 @@
+<div class="form-group entity-selector-container">
+    <div component="entity-selector"
+         class="entity-selector {{$selectorSize ?? ''}}"
+         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('common.loading-icon')</div>
+        <div refs="entity-selector@results"></div>
+        @if($showAdd ?? false)
+            <div class="entity-selector-add">
+                <button refs="entity-selector@add" type="button"
+                        class="button outline">@icon('add'){{ trans('common.add') }}</button>
+            </div>
+        @endif
+    </div>
+</div>
\ No newline at end of file
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
similarity index 93%
rename from resources/views/partials/sort.blade.php
rename to resources/views/entities/sort.blade.php
index af0981800049322a9062eef2ea81ecf9a9898354..bf90873975c7b89e483a82210ea67c263b20a79c 100644 (file)
@@ -4,7 +4,7 @@
 ?>
 <div class="list-sort-container" list-sort-control>
     <div class="list-sort-label">{{ trans('common.sort') }}</div>
-    <form action="{{ url("/settings/users/{$currentUser->id}/change-sort/{$type}") }}" method="post">
+    <form action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}" method="post">
 
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
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..ffbd5c3
--- /dev/null
@@ -0,0 +1,11 @@
+@foreach($entity->tags as $tag)
+    <div class="tag-item primary-background-light">
+        @if($linked ?? true)
+            <div class="tag-name"><a href="{{ $tag->nameUrl() }}">@icon('tag'){{ $tag->name }}</a></div>
+            @if($tag->value) <div class="tag-value"><a href="{{ $tag->valueUrl() }}">{{$tag->value}}</a></div> @endif
+        @else
+            <div class="tag-name"><span>@icon('tag'){{ $tag->name }}</span></div>
+            @if($tag->value) <div class="tag-value"><span>{{$tag->value}}</span></div> @endif
+        @endif
+    </div>
+@endforeach
\ No newline at end of file
similarity index 84%
rename from resources/views/components/tag-manager.blade.php
rename to resources/views/entities/tag-manager.blade.php
index 9e24ba3fdcedadae6140c81122e75fb4fd33750c..5975c4bd97d9676a4fb2c12746a4b0fa0595682e 100644 (file)
@@ -9,7 +9,7 @@
 
         <div component="sortable-list"
              option:sortable-list:handle-selector=".handle">
-            @include('components.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []])
+            @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>
similarity index 84%
rename from resources/views/partials/view-toggle.blade.php
rename to resources/views/entities/view-toggle.blade.php
index 9f911c88231d1775366263e0e29766705690df94..9ff1b49277d035c17f2df0169f90eab1ddbd2bfc 100644 (file)
@@ -1,5 +1,5 @@
 <div>
-    <form action="{{ url("/settings/users/{$currentUser->id}/switch-${type}-view") }}" method="POST" class="inline">
+    <form action="{{ url("/settings/users/". user()->id ."/switch-${type}-view") }}" method="POST" class="inline">
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
         <input type="hidden" value="{{ $view === 'list'? 'grid' : 'list' }}" name="view_type">
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..9f86bfdc6475d07e7fe7c8f6aed53e41d4c09bfa 100644 (file)
@@ -1,4 +1,4 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('content')
 
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',
diff --git a/resources/views/form/dropzone.blade.php b/resources/views/form/dropzone.blade.php
new file mode 100644 (file)
index 0000000..6c5ac49
--- /dev/null
@@ -0,0 +1,15 @@
+{{--
+@url - URL to upload to.
+@placeholder - Placeholder text
+@successMessage
+--}}
+<div component="dropzone"
+     option:dropzone:url="{{ $url }}"
+     option:dropzone:success-message="{{ $successMessage ?? '' }}"
+     option:dropzone:remove-message="{{ trans('components.image_upload_remove') }}"
+     option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}"
+     option:dropzone:timeout-message="{{ trans('errors.file_upload_timeout') }}"
+
+     class="dropzone-container text-center">
+    <button type="button" class="dz-message">{{ $placeholder }}</button>
+</div>
\ No newline at end of file
index 3581a545b1c96f6b082b11c3a379747251f13ad1..ed04bc04124c9c0302568b8b4135390ce1314289 100644 (file)
@@ -2,19 +2,34 @@
     {!! csrf_field() !!}
     <input type="hidden" name="_method" value="PUT">
 
-    <p class="mb-none">{{ trans('entities.permissions_intro') }}</p>
-
-    <div class="form-group">
-        @include('form.checkbox', [
-            'name' => 'restricted',
-            'label' => trans('entities.permissions_enable'),
-        ])
+    <div class="grid half left-focus v-center">
+        <div>
+            <p class="mb-none mt-m">{{ trans('entities.permissions_intro') }}</p>
+            <div>
+                @include('form.checkbox', [
+                    'name' => 'restricted',
+                    'label' => trans('entities.permissions_enable'),
+                ])
+            </div>
+        </div>
+        <div>
+            <div class="form-group">
+                <label for="owner">{{ trans('entities.permissions_owner') }}</label>
+                @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>
index a3c868a8dc08ba293e624f27aa8f1229aa5aa039..65df748772598a9f8de8498f4ba705a21041c317 100644 (file)
@@ -1,7 +1,8 @@
 <input type="password" id="{{ $name }}" name="{{ $name }}"
        @if($errors->has($name)) class="text-neg" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
+       @if(isset($autocomplete)) autocomplete="{{$autocomplete}}" @endif
        @if(old($name)) value="{{ old($name)}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
-@endif
\ No newline at end of file
+@endif
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 580b332f39c7e1fc993c670693ba08a8ad818192..7e5ca629a8598a4e9cb5b8603e53d08e0d6c9807 100644 (file)
@@ -2,11 +2,11 @@
 <div class="toggle-switch-list dual-column-content">
     @foreach($roles as $role)
         <div>
-            @include('components.custom-checkbox', [
-                'name' => $name . '[' . str_replace('.', 'DOT', $role->name) . ']',
+            @include('form.custom-checkbox', [
+                'name' => $name . '[' . strval($role->id) . ']',
                 'label' => $role->display_name,
                 'value' => $role->id,
-                'checked' => old($name . '.' . str_replace('.', 'DOT', $role->name)) || (!old('name') && isset($model) && $model->hasRole($role->name))
+                'checked' => old($name . '.' . strval($role->id)) || (!old('name') && isset($model) && $model->hasRole($role->id))
             ])
         </div>
     @endforeach
diff --git a/resources/views/form/user-select-list.blade.php b/resources/views/form/user-select-list.blade.php
new file mode 100644 (file)
index 0000000..0018d64
--- /dev/null
@@ -0,0 +1,9 @@
+<a href="#" class="flex-container-row items-center dropdown-search-item" data-id="">
+    <span>{{ trans('settings.users_none_selected') }}</span>
+</a>
+@foreach($users as $user)
+    <a href="#" class="flex-container-row items-center dropdown-search-item" data-id="{{ $user->id }}">
+        <img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
+        <span>{{ $user->name }}</span>
+    </a>
+@endforeach
\ No newline at end of file
diff --git a/resources/views/form/user-select.blade.php b/resources/views/form/user-select.blade.php
new file mode 100644 (file)
index 0000000..8823bb0
--- /dev/null
@@ -0,0 +1,34 @@
+<div class="dropdown-search custom-select-input" components="dropdown dropdown-search user-select"
+     option:dropdown-search:url="/search/users/select"
+>
+    <input refs="user-select@input" type="hidden" name="{{ $name }}" value="{{ $user->id ?? '' }}">
+    <div refs="dropdown@toggle"
+         class="dropdown-search-toggle {{ $compact ? 'compact' : '' }} flex-container-row items-center"
+         aria-haspopup="true" aria-expanded="false" tabindex="0">
+        <div refs="user-select@user-info" class="flex-container-row items-center px-s">
+            @if($user)
+                <img class="avatar small mr-m" src="{{ $user->getAvatar($compact ? 22 : 30) }}" alt="{{ $user->name }}">
+                <span>{{ $user->name }}</span>
+            @else
+                <span>{{ trans('settings.users_none_selected') }}</span>
+            @endif
+        </div>
+        <span style="font-size: {{ $compact ? '1.15rem' : '1.5rem' }}; margin-left: auto;">
+            @icon('caret-down')
+        </span>
+    </div>
+    <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu">
+        <div class="dropdown-search-search">
+            @icon('search')
+            <input refs="dropdown-search@searchInput"
+                   aria-label="{{ trans('common.search') }}"
+                   autocomplete="off"
+                   placeholder="{{ trans('common.search') }}"
+                   type="text">
+        </div>
+        <div refs="dropdown-search@loading" class="text-center">
+            @include('common.loading-icon')
+        </div>
+        <div refs="dropdown-search@listContainer" class="dropdown-search-list"></div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/home/books.blade.php b/resources/views/home/books.blade.php
new file mode 100644 (file)
index 0000000..75d4ae1
--- /dev/null
@@ -0,0 +1,26 @@
+@extends('layouts.tri')
+
+@section('body')
+    @include('books.parts.list', ['books' => $books, 'view' => $view])
+@stop
+
+@section('left')
+    @include('home.parts.sidebar')
+@stop
+
+@section('right')
+    <div class="actions mb-xl">
+        <h5>{{ trans('common.actions') }}</h5>
+        <div class="icon-list text-primary">
+            @if(user()->can('book-create-all'))
+                <a href="{{ url("/create-book") }}" class="icon-list-item">
+                    <span>@icon('add')</span>
+                    <span>{{ trans('entities.books_create') }}</span>
+                </a>
+            @endif
+            @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 56%
rename from resources/views/common/home.blade.php
rename to resources/views/home/default.blade.php
index 2631f1a57ed878b01ad5099f7a539e07169eba58..b8866526d3d21521f38f4024e3014316a38e98c2 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>
                     <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
 
-                <div id="{{ $signedIn ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
-                    <h3 class="card-title">{{ trans('entities.' . ($signedIn ? 'my_recently_viewed' : 'books_recent')) }}</h3>
+                <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' => $signedIn ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
+                        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
                         ])
                     </div>
                 </div>
             </div>
 
             <div>
+                @if(count($favourites) > 0)
+                    <div id="top-favourites" class="card mb-xl">
+                        <h3 class="card-title">
+                            <a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
+                        </h3>
+                        <div class="px-m">
+                            @include('entities.list', [
+                            'entities' => $favourites,
+                            'style' => 'compact',
+                            ])
+                        </div>
+                    </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>
                     <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')
@@ -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 74%
rename from resources/views/components/expand-toggle.blade.php
rename to resources/views/home/parts/expand-toggle.blade.php
index a24f9ac1e9ec79c7d1c55ed95250f177bf9f42ee..8ed7ff6e036167cb97a7428a57bb906d2f2e7f1e 100644 (file)
@@ -4,9 +4,9 @@ $key - Unique key for checking existing stored state.
 --}}
 <?php $isOpen = setting()->getForCurrentUser('section_expansion#'. $key); ?>
 <button type="button" expand-toggle="{{ $target }}"
-   expand-toggle-update-endpoint="{{ url('/settings/users/'. $currentUser->id .'/update-expansion-preference/' . $key) }}"
+   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..8dc8118
--- /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>
+            <a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
+        </h5>
+        @include('entities.list', [
+            'entities' => $favourites,
+            'style' => 'compact',
+        ])
+    </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><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h5>
+    <div id="recently-updated-pages">
+        @include('entities.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('common.activity-list', ['activity' => $activity])
+</div>
\ No newline at end of file
diff --git a/resources/views/home/shelves.blade.php b/resources/views/home/shelves.blade.php
new file mode 100644 (file)
index 0000000..c525643
--- /dev/null
@@ -0,0 +1,26 @@
+@extends('layouts.tri')
+
+@section('body')
+    @include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view])
+@stop
+
+@section('left')
+    @include('home.parts.sidebar')
+@stop
+
+@section('right')
+    <div class="actions mb-xl">
+        <h5>{{ trans('common.actions') }}</h5>
+        <div class="icon-list text-primary">
+            @if(user()->can('bookshelf-create-all'))
+                <a href="{{ url("/create-shelf") }}" class="icon-list-item">
+                    <span>@icon('add')</span>
+                    <span>{{ trans('entities.shelves_new_action') }}</span>
+                </a>
+            @endif
+            @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 68%
rename from resources/views/base.blade.php
rename to resources/views/layouts/base.blade.php
index a5404a36506ae9d70225034a6eeef749eeb86d28..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')
 
 </head>
 <body class="@yield('body-class')">
 
-    @include('partials.notifications')
+    @include('common.skip-to-content')
+    @include('common.notifications')
     @include('common.header')
 
-    <div id="content" class="block">
+    <div id="content" components="@yield('content-components')" class="block">
         @yield('content')
     </div>
 
+    @include('common.footer')
+
     <div back-to-top class="primary-background print-hidden">
         <div class="inner">
             @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
@@ -42,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>
diff --git a/resources/views/layouts/export.blade.php b/resources/views/layouts/export.blade.php
new file mode 100644 (file)
index 0000000..55df43a
--- /dev/null
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="{{ config('app.lang') }}">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+    <title>@yield('title')</title>
+
+    @include('common.export-styles', ['format' => $format])
+    @include('common.export-custom-head')
+</head>
+<body>
+<div class="page-content">
+    @yield('content')
+</div>
+</body>
+</html>
\ No newline at end of file
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>
diff --git a/resources/views/layouts/tri.blade.php b/resources/views/layouts/tri.blade.php
new file mode 100644 (file)
index 0000000..e95b214
--- /dev/null
@@ -0,0 +1,49 @@
+@extends('layouts.base')
+
+@section('body-class', 'tri-layout')
+@section('content-components', 'tri-layout')
+
+@section('content')
+
+    <div class="tri-layout-mobile-tabs print-hidden">
+        <div class="grid half no-break no-gap">
+            <button type="button"
+                    refs="tri-layout@tab"
+                    data-tab="info"
+                    aria-label="{{ trans('common.tab_info_label') }}"
+                    class="tri-layout-mobile-tab px-m py-m text-primary">
+                {{ trans('common.tab_info') }}
+            </button>
+            <button type="button"
+                    refs="tri-layout@tab"
+                    data-tab="content"
+                    aria-label="{{ trans('common.tab_content_label') }}"
+                    aria-selected="true"
+                    class="tri-layout-mobile-tab px-m py-m text-primary active">
+                {{ trans('common.tab_content') }}
+            </button>
+        </div>
+    </div>
+
+    <div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') >
+
+        <div class="tri-layout-left print-hidden pt-m" id="sidebar">
+            <aside class="tri-layout-left-contents">
+                @yield('left')
+            </aside>
+        </div>
+
+        <div class="@yield('body-wrap-classes') tri-layout-middle">
+            <div id="main-content" class="tri-layout-middle-contents">
+                @yield('body')
+            </div>
+        </div>
+
+        <div class="tri-layout-right print-hidden pt-m">
+            <aside class="tri-layout-right-contents">
+                @yield('right')
+            </aside>
+        </div>
+    </div>
+
+@stop
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..9a861fc
--- /dev/null
@@ -0,0 +1,17 @@
+<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"
+           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>
\ No newline at end of file
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
diff --git a/resources/views/pages/attachment-manager.blade.php b/resources/views/pages/attachment-manager.blade.php
deleted file mode 100644 (file)
index dd00678..0000000
+++ /dev/null
@@ -1,102 +0,0 @@
-<div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id ?? 0 }}">
-
-    @exposeTranslations([
-    'entities.attachments_file_uploaded',
-    'entities.attachments_file_updated',
-    'entities.attachments_link_attached',
-    'entities.attachments_updated_success',
-    'errors.server_upload_limit',
-    'components.image_upload_remove',
-    'components.file_upload_timeout',
-    ])
-
-    <h4>{{ trans('entities.attachments') }}</h4>
-    <div class="px-l files">
-
-        <div id="file-list" v-show="!fileToEdit">
-            <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
-
-            <div class="tab-container">
-                <div class="nav-tabs">
-                    <button type="button" @click="tab = 'list'" :class="{selected: tab === 'list'}"
-                            class="tab-item">{{ trans('entities.attachments_items') }}</button>
-                    <button type="button" @click="tab = 'file'" :class="{selected: tab === 'file'}"
-                            class="tab-item">{{ trans('entities.attachments_upload') }}</button>
-                    <button type="button" @click="tab = 'link'" :class="{selected: tab === 'link'}"
-                            class="tab-item">{{ trans('entities.attachments_link') }}</button>
-                </div>
-                <div v-show="tab === 'list'">
-                    <draggable style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div">
-                        <div v-for="(file, index) in files" :key="file.id" class="card drag-card">
-                            <div class="handle">@icon('grip')</div>
-                            <div class="py-s">
-                                <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a>
-                                <div v-if="file.deleting">
-                                    <span class="text-neg small">{{ trans('entities.attachments_delete_confirm') }}</span>
-                                    <br>
-                                    <button type="button" class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</button>
-                                </div>
-                            </div>
-                            <button type="button" @click="startEdit(file)" class="drag-card-action text-center text-primary">@icon('edit')</button>
-                            <button type="button" @click="deleteFile(file)" class="drag-card-action text-center text-neg">@icon('close')</button>
-                        </div>
-                    </draggable>
-                    <p class="small text-muted" v-if="files.length === 0">
-                        {{ trans('entities.attachments_no_files') }}
-                    </p>
-                </div>
-                <div v-show="tab === 'file'">
-                    <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone>
-                </div>
-                <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)">
-                    <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
-                    <div class="form-group">
-                        <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label>
-                        <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name">
-                        <p class="small text-neg" v-for="error in errors.link.name" v-text="error"></p>
-                    </div>
-                    <div class="form-group">
-                        <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label>
-                        <input type="text"  placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link">
-                        <p class="small text-neg" v-for="error in errors.link.link" v-text="error"></p>
-                    </div>
-                    <button @click.prevent="attachNewLink(file)" class="button">{{ trans('entities.attach') }}</button>
-
-                </div>
-            </div>
-
-        </div>
-
-        <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)">
-            <h5>{{ trans('entities.attachments_edit_file') }}</h5>
-
-            <div class="form-group">
-                <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label>
-                <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name">
-                <p class="small text-neg" v-for="error in errors.edit.name" v-text="error"></p>
-            </div>
-
-            <div class="tab-container">
-                <div class="nav-tabs">
-                    <button type="button" @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</button>
-                    <button type="button" @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</button>
-                </div>
-                <div v-if="editTab === 'file'">
-                    <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone>
-                    <br>
-                </div>
-                <div v-if="editTab === 'link'">
-                    <div class="form-group">
-                        <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label>
-                        <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link">
-                        <p class="small text-neg" v-for="error in errors.edit.link" v-text="error"></p>
-                    </div>
-                </div>
-            </div>
-
-            <button type="button" class="button outline" @click="cancelEdit">{{ trans('common.back') }}</button>
-            <button @click.enter.prevent="updateFile(fileToEdit)" class="button">{{ trans('common.save') }}</button>
-        </div>
-
-    </div>
-</div>
\ No newline at end of file
index 0f2af0476e17143b5f8a48df42fccd75d0892f2a..2f24d8165413c9e3e3fcd6586bd1dc82965af23e 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,
@@ -33,7 +33,7 @@
                         <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>
 
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 cfb66fdd0e34ad87827be468764670b230bc2a3b..6d2c3d484d43574ede66250a58347cef8c3f2692 100644 (file)
@@ -1,27 +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">
+    <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', ['imageType' => 'gallery', '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 47a4d870a041be8e10d4a11704e5b2bd9a4bb1d2..d2f448d6e889bd430f3ce53852530d8411c2945c 100644 (file)
@@ -1,51 +1,13 @@
-<!doctype html>
-<html lang="{{ config('app.lang') }}">
-<head>
-    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
-    <title>{{ $page->name }}</title>
+@extends('layouts.export')
 
-    @include('partials.export-styles', ['format' => $format])
+@section('title', $page->name)
 
-    @if($format === 'pdf')
-        <style>
-            body {
-                font-size: 14px;
-                line-height: 1.2;
-            }
+@section('content')
+    @include('pages.parts.page-display')
 
-            h1, h2, h3, h4, h5, h6 {
-                line-height: 1.2;
-            }
-
-            table {
-                max-width: 800px !important;
-                font-size: 0.8em;
-                width: 100% !important;
-            }
-
-            table td {
-                width: auto !important;
-            }
-        </style>
-    @endif
-
-    @include('partials.custom-head')
-</head>
-<body>
-
-<div id="page-show">
-    <div class="page-content">
-
-        @include('pages.page-display')
-
-        <hr>
-
-        <div class="text-muted text-small">
-            @include('partials.entity-export-meta', ['entity' => $page])
-        </div>
+    <hr>
 
+    <div class="text-muted text-small">
+        @include('entities.export-meta', ['entity' => $page])
     </div>
-</div>
-
-</body>
-</html>
+@endsection
\ No newline at end of file
diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php
deleted file mode 100644 (file)
index 47a9369..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-<div class="page-editor flex-fill flex" id="page-editor"
-     drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
-     @if(config('services.drawio'))
-        drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://www.draw.io/?embed=1&proto=json&spin=1' }}"
-     @endif
-     editor-type="{{ setting('app-editor') }}"
-     page-id="{{ $model->id ?? 0 }}"
-     text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
-     page-new-draft="{{ $model->draft ?? 0 }}"
-     page-update-draft="{{ $model->isDraft ?? 0 }}">
-
-    @exposeTranslations([
-        'entities.pages_editing_draft',
-        'entities.pages_editing_page',
-        'errors.page_draft_autosave_fail',
-        'entities.pages_editing_page',
-        'entities.pages_draft_discarded',
-        'entities.pages_edit_set_changelog',
-    ])
-
-    {{--Header Bar--}}
-    <div class="primary-background-light toolbar page-edit-toolbar">
-        <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>
-            </div>
-
-            <div class="text-center px-m py-xs">
-                <div v-show="draftsEnabled"
-                     component="dropdown"
-                     option:dropdown:move-menu="true"
-                     class="dropdown-container draft-display text">
-                    <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button"><span class="faded-text" v-text="draftText"></span>&nbsp; @icon('more')</button>
-                    @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', ':class' => '{visible: draftUpdated}'])
-                    <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
-                        <li>
-                            <button type="button" @click="saveDraft()" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</button>
-                        </li>
-                        <li v-if="isNewDraft">
-                            <a href="{{ $model->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('entities.pages_edit_delete_draft') }}</a>
-                        </li>
-                        <li v-if="isUpdateDraft">
-                            <button type="button" @click="discardDraft" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</button>
-                        </li>
-                    </ul>
-                </div>
-            </div>
-
-            <div class="action-buttons px-m py-xs" v-cloak>
-                <div component="dropdown" dropdown-move-menu class="dropdown-container">
-                    <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span v-text="changeSummaryShort"></span></button>
-                    <ul refs="dropdown@menu" class="wide dropdown-menu">
-                        <li class="px-l py-m">
-                            <p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
-                            <input name="summary" id="summary-input" type="text" placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" v-model="changeSummary" />
-                        </li>
-                    </ul>
-                    <span>{{-- Prevents button jumping on menu show --}}</span>
-                </div>
-
-                <button type="submit" id="save-button" class="float-left text-primary text-button text-pos-hover hide-under-m">@icon('save')<span>{{ trans('entities.pages_save') }}</span></button>
-            </div>
-        </div>
-    </div>
-
-    {{--Title input--}}
-    <div class="title-input page-title clearfix" v-pre>
-        <div class="input" @if($model->name === trans('entities.pages_initial_name')) is-default-value @endif>
-            @include('form.text', ['name' => 'name', 'model' => $model, 'placeholder' => trans('entities.pages_title')])
-        </div>
-    </div>
-
-    {{--Editors--}}
-    <div class="edit-area flex-fill flex">
-
-        {{--WYSIWYG Editor--}}
-        @if(setting('app-editor') === 'wysiwyg')
-            @include('pages.wysiwyg-editor', ['model' => $model])
-        @endif
-
-        {{--Markdown Editor--}}
-        @if(setting('app-editor') === 'markdown')
-            @include('pages.markdown-editor', ['model' => $model])
-        @endif
-
-    </div>
-
-    <button type="submit" id="save-button-mobile" title="{{ trans('entities.pages_save') }}" class="text-primary text-button hide-over-m page-save-mobile-button">@icon('save')</button>
-</div>
\ 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 3bf1db5e46671f98ead3e3cd8c879f0bb92887ec..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'])
+                @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 95%
rename from resources/views/components/code-editor.blade.php
rename to resources/views/pages/parts/code-editor.blade.php
index 6822bb28d00ad1823571c0bcfceaa044d0682a6b..c593d0e2389adde14d7091a3f3d7b73c19a1ae10 100644 (file)
@@ -34,6 +34,8 @@
                             <a refs="code-editor@languageLink" data-lang="Ruby">Ruby</a>
                             <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>
@@ -66,4 +68,4 @@
 
         </div>
     </div>
-</div>
\ No newline at end of file
+</div>
similarity index 81%
rename from resources/views/pages/editor-toolbox.blade.php
rename to resources/views/pages/parts/editor-toolbox.blade.php
index 3741c9246ef982ffa75973196043e5f394fd57e3..f3b54ddcd6eb0efb0978dea032555a54feeeab1d 100644 (file)
     <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>
 
     @if(userCan('attachment-create-all'))
-        @include('pages.attachment-manager', ['page' => $page])
+        @include('attachments.manager', ['page' => $page])
     @endif
 
     <div toolbox-tab-content="templates">
         <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>
diff --git a/resources/views/pages/parts/form.blade.php b/resources/views/pages/parts/form.blade.php
new file mode 100644 (file)
index 0000000..f6f0143
--- /dev/null
@@ -0,0 +1,95 @@
+<div component="page-editor" class="page-editor flex-fill flex"
+     option:page-editor:drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
+     @if(config('services.drawio'))
+        drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://embed.diagrams.net/?embed=1&proto=json&spin=1' }}"
+     @endif
+     @if($model->name === trans('entities.pages_initial_name'))
+        option:page-editor:has-default-title="true"
+     @endif
+     option:page-editor:editor-type="{{ setting('app-editor') }}"
+     option:page-editor:page-id="{{ $model->id ?? '0' }}"
+     option:page-editor:page-new-draft="{{ ($model->draft ?? false) ? 'true' : 'false' }}"
+     option:page-editor:draft-text="{{ ($model->draft || $model->isDraft) ? trans('entities.pages_editing_draft') : trans('entities.pages_editing_page') }}"
+     option:page-editor:autosave-fail-text="{{ trans('errors.page_draft_autosave_fail') }}"
+     option:page-editor:editing-page-text="{{ trans('entities.pages_editing_page') }}"
+     option:page-editor:draft-discarded-text="{{ trans('entities.pages_draft_discarded') }}"
+     option:page-editor:set-changelog-text="{{ trans('entities.pages_edit_set_changelog') }}">
+
+    {{--Header Bar--}}
+    <div class="primary-background-light toolbar page-edit-toolbar">
+        <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>
+            </div>
+
+            <div class="text-center px-m py-xs">
+                <div component="dropdown"
+                     option:dropdown:move-menu="true"
+                     class="dropdown-container draft-display text {{ $draftsEnabled ? '' : 'hidden' }}">
+                    <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button"><span refs="page-editor@draftDisplay" class="faded-text"></span>&nbsp; @icon('more')</button>
+                    @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', 'refs' => 'page-editor@draftDisplayIcon'])
+                    <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+                        <li>
+                            <button refs="page-editor@saveDraft" type="button" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</button>
+                        </li>
+                        @if ($model->draft)
+                        <li>
+                            <a href="{{ $model->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('entities.pages_edit_delete_draft') }}</a>
+                        </li>
+                        @endif
+                        <li refs="page-editor@discardDraftWrap" class="{{ ($model->isDraft ?? false) ? '' : 'hidden' }}">
+                            <button refs="page-editor@discardDraft" type="button" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</button>
+                        </li>
+                    </ul>
+                </div>
+            </div>
+
+            <div class="action-buttons px-m py-xs">
+                <div component="dropdown" dropdown-move-menu class="dropdown-container">
+                    <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span></button>
+                    <ul refs="dropdown@menu" class="wide dropdown-menu">
+                        <li class="px-l py-m">
+                            <p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
+                            <input refs="page-editor@changelogInput"
+                                   name="summary"
+                                   id="summary-input"
+                                   type="text"
+                                   placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" />
+                        </li>
+                    </ul>
+                    <span>{{-- Prevents button jumping on menu show --}}</span>
+                </div>
+
+                <button type="submit" id="save-button" class="float-left text-primary text-button text-pos-hover hide-under-m">@icon('save')<span>{{ trans('entities.pages_save') }}</span></button>
+            </div>
+        </div>
+    </div>
+
+    {{--Title input--}}
+    <div class="title-input page-title clearfix">
+        <div refs="page-editor@titleContainer" class="input">
+            @include('form.text', ['name' => 'name', 'model' => $model, 'placeholder' => trans('entities.pages_title')])
+        </div>
+    </div>
+
+    {{--Editors--}}
+    <div class="edit-area flex-fill flex">
+
+        {{--WYSIWYG Editor--}}
+        @if(setting('app-editor') === 'wysiwyg')
+            @include('pages.parts.wysiwyg-editor', ['model' => $model])
+        @endif
+
+        {{--Markdown Editor--}}
+        @if(setting('app-editor') === 'markdown')
+            @include('pages.parts.markdown-editor', ['model' => $model])
+        @endif
+
+    </div>
+
+    <button type="submit"
+            id="save-button-mobile"
+            title="{{ trans('entities.pages_save') }}"
+            class="text-primary text-button hide-over-m page-save-mobile-button">@icon('save')</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php
new file mode 100644 (file)
index 0000000..6d62552
--- /dev/null
@@ -0,0 +1,61 @@
+<div class="image-manager-details">
+
+    <form component="ajax-form"
+          option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
+          option:ajax-form:method="put"
+          option:ajax-form:response-container=".image-manager-details"
+          option:ajax-form:url="{{ url('images/' . $image->id) }}">
+
+        <div class="image-manager-viewer">
+            <a href="{{ $image->url }}" target="_blank" rel="noopener" class="block">
+                <img src="{{ $image->thumbs['display'] }}"
+                     alt="{{ $image->name }}"
+                     class="anim fadeIn"
+                     title="{{ $image->name }}">
+            </a>
+        </div>
+        <div class="form-group stretch-inputs">
+            <label for="name">{{ trans('components.image_image_name') }}</label>
+            <input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
+        </div>
+        <div class="grid half">
+            <div>
+                <button type="button"
+                        id="image-manager-delete"
+                        title="{{ trans('common.delete') }}"
+                        class="button icon outline">@icon('delete')</button>
+            </div>
+            <div class="text-right">
+                <button type="submit"
+                        class="button icon outline">{{ trans('common.save') }}</button>
+            </div>
+        </div>
+    </form>
+
+    @if(!is_null($dependantPages))
+        @if(count($dependantPages) > 0)
+            <p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
+            <ul class="text-neg">
+                @foreach($dependantPages as $page)
+                    <li>
+                        <a href="{{ $page->url }}"
+                           target="_blank"
+                           rel="noopener"
+                           class="text-neg">{{ $page->name }}</a>
+                    </li>
+                @endforeach
+            </ul>
+        @endif
+        <p class="text-neg mb-xs">{{ trans('components.image_delete_confirm_text') }}</p>
+        <form component="ajax-form"
+              option:ajax-form:success-message="{{ trans('components.image_delete_success') }}"
+              option:ajax-form:method="delete"
+              option:ajax-form:response-container=".image-manager-details"
+              option:ajax-form:url="{{ url('images/' . $image->id) }}">
+            <button type="submit" class="button neg">
+                {{ trans('common.delete_confirm') }}
+            </button>
+        </form>
+    @endif
+
+</div>
\ No newline at end of file
diff --git a/resources/views/pages/parts/image-manager-list.blade.php b/resources/views/pages/parts/image-manager-list.blade.php
new file mode 100644 (file)
index 0000000..e5562e1
--- /dev/null
@@ -0,0 +1,23 @@
+@foreach($images as $index => $image)
+<div>
+    <div component="event-emit-select"
+         option:event-emit-select:name="image"
+         option:event-emit-select:data="{{ json_encode($image) }}"
+         class="image anim fadeIn text-primary"
+         style="animation-delay: {{ $index > 26 ? '160ms' : ($index * 25) . 'ms' }};">
+        <img src="{{ $image->thumbs['gallery'] }}"
+             alt="{{ $image->name }}"
+             width="150"
+             height="150"
+             loading="lazy"
+             title="{{ $image->name }}">
+        <div class="image-meta">
+            <span class="name">{{ $image->name }}</span>
+            <span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d H:i:s')]) }}</span>
+        </div>
+    </div>
+</div>
+@endforeach
+@if($hasMore)
+    <div class="load-more">{{ trans('components.image_load_more') }}</div>
+@endif
\ No newline at end of file
diff --git a/resources/views/pages/parts/image-manager.blade.php b/resources/views/pages/parts/image-manager.blade.php
new file mode 100644 (file)
index 0000000..c15c31b
--- /dev/null
@@ -0,0 +1,66 @@
+<div component="image-manager"
+     option:image-manager:uploaded-to="{{ $uploaded_to ?? 0 }}"
+     class="image-manager">
+
+    <div component="popup"
+         refs="image-manager@popup"
+         class="popup-background">
+        <div class="popup-body" tabindex="-1">
+
+            <div class="popup-header primary-background">
+                <div class="popup-title">{{ trans('components.image_select') }}</div>
+                <button refs="popup@hide" type="button" class="popup-header-close">x</button>
+            </div>
+
+            <div class="flex-fill image-manager-body">
+
+                <div class="image-manager-content">
+                    <div class="image-manager-header primary-background-light nav-tabs grid third no-gap">
+                        <button refs="image-manager@filterTabs"
+                                data-filter="all"
+                                type="button" class="tab-item selected" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }}</button>
+                        <button refs="image-manager@filterTabs"
+                                data-filter="book"
+                                type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'text-book svg-icon']) {{ trans('entities.book') }}</button>
+                        <button refs="image-manager@filterTabs"
+                                data-filter="page"
+                                type="button" class="tab-item" title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'text-page svg-icon']) {{ trans('entities.page') }}</button>
+                    </div>
+                    <div>
+                        <form refs="image-manager@searchForm" class="contained-search-box">
+                            <input refs="image-manager@searchInput"
+                                   placeholder="{{ trans('components.image_search_hint') }}"
+                                   type="text">
+                            <button refs="image-manager@cancelSearch"
+                                    title="{{ trans('common.search_clear') }}"
+                                    type="button"
+                                    class="cancel">@icon('close')</button>
+                            <button type="submit" class="primary-background text-white"
+                                    title="{{ trans('common.search') }}">@icon('search')</button>
+                        </form>
+                    </div>
+                    <div refs="image-manager@listContainer" class="image-manager-list"></div>
+                </div>
+
+                <div class="image-manager-sidebar flex-container-column">
+
+                    <div refs="image-manager@dropzoneContainer">
+                        @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]))
+                        ])
+                    </div>
+
+                    <div refs="image-manager@formContainer" class="inner flex"></div>
+
+                    <button refs="image-manager@selectButton" type="button" class="hidden button corner-button">
+                        {{ trans('components.image_select_image') }}
+                    </button>
+                </div>
+
+            </div>
+
+        </div>
+    </div>
+</div>
\ No newline at end of file
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 77%
rename from resources/views/pages/markdown-editor.blade.php
rename to resources/views/pages/parts/markdown-editor.blade.php
index 85afbea069ab4726c6588971445e439d65ebb5d2..39d628e17d0ce6ec503d6f8ea76051fa7d1238a2 100644 (file)
@@ -1,7 +1,9 @@
-<div v-pre id="markdown-editor" markdown-editor class="flex-fill flex code-fill">
-    @exposeTranslations([
-        'errors.image_upload_error',
-    ])
+<div id="markdown-editor" component="markdown-editor"
+     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">
         <div class="editor-toolbar">
         <div class="editor-toolbar">
             <div class="editor-toolbar-label">{{ trans('entities.pages_md_preview') }}</div>
         </div>
-        <iframe srcdoc="" class="markdown-display" sandbox="allow-same-origin"></iframe>
+        <iframe src="about:blank" class="markdown-display" sandbox="allow-same-origin"></iframe>
     </div>
-    <input type="hidden" name="html"/>
-
 </div>
 
 
similarity index 71%
rename from resources/views/pages/page-display.blade.php
rename to resources/views/pages/parts/page-display.blade.php
index e13632c1ec86a41b3c1818be59add6b46682679f..ba2a2c336fd60cd0fce5b8761d176285f207098d 100644 (file)
@@ -1,6 +1,6 @@
 <div dir="auto">
 
-    <h1 class="break-text" v-pre id="bkmrk-page-title">{{$page->name}}</h1>
+    <h1 class="break-text" id="bkmrk-page-title">{{$page->name}}</h1>
 
     <div style="clear:left;"></div>
 
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
diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php
new file mode 100644 (file)
index 0000000..02948fa
--- /dev/null
@@ -0,0 +1,14 @@
+<div component="wysiwyg-editor"
+     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"
+          @if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>
+</div>
+
+@if($errors->has('html'))
+    <div class="text-neg text-small">{{ $errors->first('html') }}</div>
+@endif
\ No newline at end of file
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 b53ed88a9f867dc7b466f6f4848a2984193cdbdf..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>
@@ -66,7 +66,7 @@
                                     <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('common.delete') }}</a>
-                                        <ul refs="dropdown@menu" role="menu">
+                                        <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
                                             <li class="px-m py-s"><small class="text-muted">{{trans('entities.revision_delete_confirm')}}</small></li>
                                             <li>
                                                 <form action="{{ $revision->getUrl('/delete/') }}" method="POST">
index cfa5ee9ce08572c3225a4223f5d6ac24e3e257d6..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
 
         <div id="page-attachments" class="mb-l">
             <h5>{{ trans('entities.pages_attachments') }}</h5>
             <div class="body">
-                @foreach($page->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
+                @include('attachments.list', ['attachments' => $page->attachments])
             </div>
         </div>
     @endif
@@ -56,7 +61,7 @@
                 <div class="sidebar-page-nav menu">
                     @foreach($pageNav as $navItem)
                         <li class="page-nav-item h{{ $navItem['level'] }}">
-                            <a href="{{ $navItem['link'] }}" class="limit-text block">{{ $navItem['text'] }}</a>
+                            <a href="{{ $navItem['link'] }}" class="text-limit-lines-1 block">{{ $navItem['text'] }}</a>
                             <div class="primary-background sidebar-page-nav-bullet"></div>
                         </li>
                     @endforeach
         </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/pages/wysiwyg-editor.blade.php b/resources/views/pages/wysiwyg-editor.blade.php
deleted file mode 100644 (file)
index 1a67ee7..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<div wysiwyg-editor class="flex-fill flex">
-
-    @exposeTranslations([
-        'errors.image_upload_error',
-    ])
-
-    <textarea id="html-editor"  name="html" rows="5" v-pre
-          @if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>
-</div>
-
-@if($errors->has('html'))
-    <div class="text-neg text-small">{{ $errors->first('html') }}</div>
-@endif
\ No newline at end of file
diff --git a/resources/views/partials/breadcrumb-listing.blade.php b/resources/views/partials/breadcrumb-listing.blade.php
deleted file mode 100644 (file)
index a1a33ae..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<div class="breadcrumb-listing" component="dropdown" breadcrumb-listing="{{ $entity->getType() }}:{{ $entity->id }}">
-    <div class="breadcrumb-listing-toggle" refs="dropdown@toggle"
-         aria-haspopup="true" aria-expanded="false" tabindex="0">
-        <div class="separator">@icon('chevron-right')</div>
-    </div>
-    <div refs="dropdown@menu" class="breadcrumb-listing-dropdown card" role="menu">
-        <div class="breadcrumb-listing-search">
-            @icon('search')
-            <input autocomplete="off" type="text" name="entity-search" placeholder="{{ trans('common.search') }}" aria-label="{{ trans('common.search') }}">
-        </div>
-        @include('partials.loading-icon')
-        <div class="breadcrumb-listing-entity-list px-m"></div>
-    </div>
-</div>
\ No newline at end of file
diff --git a/resources/views/partials/custom-head-content.blade.php b/resources/views/partials/custom-head-content.blade.php
deleted file mode 100644 (file)
index b245b7a..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-@if(setting('app-custom-head', false))
-    <!-- Custom user content -->
-    {!! setting('app-custom-head') !!}
-    <!-- End custom user content -->
-@endif
\ No newline at end of file
diff --git a/resources/views/partials/custom-head.blade.php b/resources/views/partials/custom-head.blade.php
deleted file mode 100644 (file)
index dd7cc41..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-@if(setting('app-custom-head') && \Route::currentRouteName() !== 'settings')
-    <!-- Custom user content -->
-    {!! setting('app-custom-head') !!}
-    <!-- End custom user content -->
-@endif
\ No newline at end of file
diff --git a/resources/views/partials/entity-export-meta.blade.php b/resources/views/partials/entity-export-meta.blade.php
deleted file mode 100644 (file)
index fa1394e..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-<div class="entity-meta">
-    @if($entity->isA('revision'))
-        @icon('history'){{ trans('entities.pages_revision') }}
-        {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
-        <br>
-    @endif
-
-    @if ($entity->isA('page'))
-        @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif
-        @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
-        @if (userCan('page-update', $entity))</a>@endif
-    @endif
-
-    @if ($entity->createdBy)
-        @icon('star'){!! trans('entities.meta_created_name', [
-            'timeLength' => '<span>'.$entity->created_at->toDayDateTimeString() . '</span>',
-            'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".htmlentities($entity->createdBy->name). "</a>"
-            ]) !!}
-    @else
-        @icon('star')<span>{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->toDayDateTimeString()]) }}</span>
-    @endif
-
-    <br>
-
-    @if ($entity->updatedBy)
-        @icon('edit'){!! trans('entities.meta_updated_name', [
-                'timeLength' => '<span>' . $entity->updated_at->toDayDateTimeString() .'</span>',
-                'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".htmlentities($entity->updatedBy->name). "</a>"
-            ]) !!}
-    @elseif (!$entity->isA('revision'))
-        @icon('edit')<span>{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->toDayDateTimeString()]) }}</span>
-    @endif
-</div>
\ No newline at end of file
diff --git a/resources/views/partials/entity-meta.blade.php b/resources/views/partials/entity-meta.blade.php
deleted file mode 100644 (file)
index f759ea2..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-<div class="entity-meta">
-    @if($entity->isA('revision'))
-        @icon('history'){{ trans('entities.pages_revision') }}
-        {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
-        <br>
-    @endif
-
-    @if ($entity->isA('page'))
-        @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif
-            @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
-            @if (userCan('page-update', $entity))</a>@endif
-    @endif
-
-
-    @if ($entity->createdBy)
-        @icon('star'){!! trans('entities.meta_created_name', [
-            'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
-            'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".htmlentities($entity->createdBy->name). "</a>"
-            ]) !!}
-    @else
-        @icon('star')<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
-    @endif
-
-    <br>
-
-    @if ($entity->updatedBy)
-        @icon('edit'){!! trans('entities.meta_updated_name', [
-                'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
-                'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".htmlentities($entity->updatedBy->name). "</a>"
-            ]) !!}
-    @elseif (!$entity->isA('revision'))
-        @icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
-    @endif
-</div>
\ 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 df137bd2a3bcbef73662ff1355b921df8d76f14c..b9adccca79b893d32abc6e6af090335a4681036f 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.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])
 
                         <button type="submit" class="button">{{ trans('entities.search_update') }}</button>
                     </form>
@@ -74,7 +77,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)
diff --git a/resources/views/search/book.blade.php b/resources/views/search/book.blade.php
deleted file mode 100644 (file)
index 36732c2..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<div class="page-list">
-    @if(count($pages) > 0)
-        @foreach($pages as $pageIndex => $page)
-            <div class="anim searchResult" style="animation-delay: {{$pageIndex*50 . 'ms'}};">
-                @include('pages.list-item', ['page' => $page])
-                <hr>
-            </div>
-        @endforeach
-    @else
-        <p class="text-muted">{{ trans('entities.search_no_pages') }}</p>
-    @endif
-</div>
-
-@if(count($chapters) > 0)
-    <div class="page-list">
-        @foreach($chapters as $chapterIndex => $chapter)
-            <div class="anim searchResult" style="animation-delay: {{($chapterIndex+count($pages))*50 . 'ms'}};">
-                @include('chapters.list-item', ['chapter' => $chapter, 'hidePages' => true])
-                <hr>
-            </div>
-        @endforeach
-    </div>
-@endif
-
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
diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
new file mode 100644 (file)
index 0000000..84f180f
--- /dev/null
@@ -0,0 +1,103 @@
+@extends('layouts.simple')
+
+@section('body')
+<div class="container">
+
+    <div class="grid left-focus v-center no-row-gap">
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'audit'])
+        </div>
+    </div>
+
+    <div class="card content-wrap auto-height">
+        <h2 class="list-heading">{{ trans('settings.audit') }}</h2>
+        <p class="text-muted">{{ trans('settings.audit_desc') }}</p>
+
+        <div class="flex-container-row">
+            <div component="dropdown" class="list-sort-type dropdown-container mr-m">
+                <label for="">{{ trans('settings.audit_event_filter') }}</label>
+                <button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
+                <ul refs="dropdown@menu" class="dropdown-menu">
+                    <li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
+                    @foreach($activityTypes as $type)
+                        <li @if($type === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $type]) }}">{{ $type }}</a></li>
+                    @endforeach
+                </ul>
+            </div>
+
+            <form action="{{ url('/settings/audit') }}" method="get" class="flex-container-row mr-m">
+                @if(!empty($listDetails['event']))
+                    <input type="hidden" name="event" value="{{ $listDetails['event'] }}">
+                @endif
+
+                @foreach(['date_from', 'date_to'] as $filterKey)
+                    <div class="mr-m">
+                        <label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label>
+                        <input id="audit_filter_{{ $filterKey }}"
+                               component="submit-on-change"
+                               type="date"
+                               name="{{ $filterKey }}"
+                               value="{{ $listDetails[$filterKey] ?? '' }}">
+                    </div>
+                @endforeach
+
+                <div class="form-group ml-auto"
+                     component="submit-on-change"
+                     option:submit-on-change:filter='[name="user"]'>
+                    <label for="owner">{{ trans('settings.audit_table_user') }}</label>
+                    @include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' =>  true])
+                </div>
+            </form>
+        </div>
+
+        <hr class="mt-l mb-s">
+
+        {{ $activities->links() }}
+
+        <table class="table">
+            <tbody>
+            <tr>
+                <th>{{ trans('settings.audit_table_user') }}</th>
+                <th>
+                    <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('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
+                    </td>
+                    <td>{{ $activity->type }}</td>
+                    <td width="40%">
+                        @if($activity->entity)
+                            <a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
+                                <span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
+                                <div class="text-{{ $activity->entity->getType() }}">
+                                    {{ $activity->entity->name }}
+                                </div>
+                            </a>
+                        @elseif($activity->detail && $activity->isForEntity())
+                            <div class="px-m">
+                                {{ trans('settings.audit_deleted_item') }} <br>
+                                {{ trans('settings.audit_deleted_item_name', ['name' => $activity->detail]) }}
+                            </div>
+                        @elseif($activity->detail)
+                            <div class="px-m">{{ $activity->detail }}</div>
+                        @endif
+                    </td>
+                    <td>{{ $activity->ip }}</td>
+                    <td>{{ $activity->created_at }}</td>
+                </tr>
+            @endforeach
+            </tbody>
+        </table>
+
+        {{ $activities->links() }}
+    </div>
+
+</div>
+@stop
index 500db64e6554dac4fcb0856f68f4b02d0cb2b2da..8d63244e19bf5b8fa9af6ac8d6a41123f1d860f9 100644 (file)
@@ -1,18 +1,9 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
     <div class="container small">
 
-        <div class="grid left-focus v-center no-row-gap">
-            <div class="py-m">
-                @include('settings.navbar', ['selected' => 'settings'])
-            </div>
-            <div class="text-right p-m">
-                <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>
+        @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>
@@ -34,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'),
@@ -48,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'),
@@ -62,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'),
@@ -94,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.parts.footer-links', ['name' => 'setting-app-footer-links', 'value' => setting('app-footer-links', [])])
+                    </div>
+
 
                     <div>
                         <label for="setting-app-custom-head" class="setting-list-label">{{ trans('settings.app_custom_html') }}</label>
                             <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')
                             <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>
                                 @foreach(\BookStack\Auth\Role::all() as $role)
-                                    <option value="{{$role->id}}" data-role-name="{{ $role->name }}"
+                                    <option value="{{$role->id}}"
+                                            data-system-role-name="{{ $role->system_name ?? '' }}"
                                             @if(setting('registration-role', \BookStack\Auth\Role::first()->id) == $role->id) selected @endif
                                     >
                                         {{ $role->display_name }}
                             <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.image-manager', ['imageType' => 'system'])
-    @include('components.entity-selector-popup', ['entityTypes' => 'page'])
+    @include('entities.selector-popup', ['entityTypes' => 'page'])
 @stop
index 7311bbbe20c96ab7ea15fe5d48ed817985a358e8..ea94413f2f7028673b9d80177f4f14f20d6b2d3e 100644 (file)
@@ -1,16 +1,25 @@
-@extends('simple-layout')
+@extends('layouts.simple')
 
 @section('body')
 <div class="container small">
 
-    <div class="grid left-focus v-center no-row-gap">
-        <div class="py-m">
-            @include('settings.navbar', ['selected' => 'maintenance'])
-        </div>
-        <div class="text-right p-m">
-            <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
-            BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
-            </a>
+    @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>
+        <div class="grid half gap-xl">
+            <div>
+                <p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
+            </div>
+            <div>
+                <div class="grid half no-gap mb-m">
+                    <p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
+                    <p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
+                    <p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
+                    <p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
+                </div>
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
+            </div>
         </div>
     </div>
 
@@ -24,7 +33,7 @@
                 <form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
                     {!! csrf_field()  !!}
                     <input type="hidden" name="_method" value="DELETE">
-                    <div>
+                    <div class="mb-s">
                         @if(session()->has('cleanup-images-warning'))
                             <p class="text-neg">
                                 {{ session()->get('cleanup-images-warning') }}
@@ -32,9 +41,9 @@
                             <input type="hidden" name="ignore_revisions" value="{{ session()->getOldInput('ignore_revisions', 'false') }}">
                             <input type="hidden" name="confirm" value="true">
                         @else
-                            <label>
-                                <input type="checkbox" name="ignore_revisions" value="true">
-                                {{ trans('settings.maint_image_cleanup_ignore_revisions') }}
+                            <label class="flex-container-row">
+                                <div class="mr-s"><input type="checkbox" name="ignore_revisions" value="true"></div>
+                                <div>{{ trans('settings.maint_delete_images_only_in_revisions') }}</div>
                             </label>
                         @endif
                     </div>
diff --git a/resources/views/settings/parts/footer-links.blade.php b/resources/views/settings/parts/footer-links.blade.php
new file mode 100644 (file)
index 0000000..10bf756
--- /dev/null
@@ -0,0 +1,34 @@
+{{--
+$value - Setting value
+$name - Setting input name
+--}}
+<div components="add-remove-rows"
+     option:add-remove-rows:row-selector=".card"
+     option:add-remove-rows:remove-selector="button.text-neg">
+
+    <div component="sortable-list"
+         option:sortable-list:handle-selector=".handle">
+        @foreach(array_merge($value, [['label' => '', 'url' => '']]) as $index => $link)
+            <div class="card drag-card {{ $loop->last ? 'hidden' : '' }}" @if($loop->last) refs="add-remove-rows@model" @endif>
+                <div class="handle">@icon('grip')</div>
+                @foreach(['label', 'url'] as $prop)
+                    <div class="outline">
+                        <input value="{{ $link[$prop] ?? '' }}"
+                               placeholder="{{ trans('settings.app_footer_links_' . $prop) }}"
+                               aria-label="{{ trans('settings.app_footer_links_' . $prop) }}"
+                               name="{{ $name }}[{{ $loop->parent->last ? 'randrowid' : $index }}][{{$prop}}]"
+                               type="text"
+                               autocomplete="off"/>
+                    </div>
+                @endforeach
+                <button type="button"
+                        aria-label="{{ trans('common.remove') }}"
+                        class="text-center drag-card-action text-neg">
+                    @icon('close')
+                </button>
+            </div>
+        @endforeach
+    </div>
+
+    <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('settings.app_footer_links_add') }}</button>
+</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..09af699
--- /dev/null
@@ -0,0 +1,15 @@
+{{--
+$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 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
similarity index 66%
rename from resources/views/settings/navbar.blade.php
rename to resources/views/settings/parts/navbar.blade.php
index 896de9d97477c0c3e510ca806ed7e33cbf12e611..a472196c56e7bded70e893953f7383918257dca0 100644 (file)
@@ -1,13 +1,16 @@
 
 <nav class="active-link-list">
-    @if($currentUser->can('settings-manage'))
+    @if(userCan('settings-manage'))
         <a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
         <a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
     @endif
-    @if($currentUser->can('users-manage'))
+    @if(userCan('settings-manage') && userCan('users-manage'))
+        <a href="{{ url('/settings/audit') }}" @if($selected == 'audit') class="active" @endif>@icon('open-book'){{ trans('settings.audit') }}</a>
+    @endif
+    @if(userCan('users-manage'))
         <a href="{{ url('/settings/users') }}" @if($selected == 'users') class="active" @endif>@icon('users'){{ trans('settings.users') }}</a>
     @endif
-    @if($currentUser->can('user-roles-manage'))
+    @if(userCan('user-roles-manage'))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</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 e24ea49f1c82a7a374f8c8cf0c51392a40cac943..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\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/parts/table-user.blade.php b/resources/views/settings/parts/table-user.blade.php
new file mode 100644 (file)
index 0000000..a8f2777
--- /dev/null
@@ -0,0 +1,12 @@
+{{--
+$user - User mode to display, Can be null.
+$user_id - Id of user to show. Must be provided.
+--}}
+@if($user)
+    <a href="{{ $user->getEditUrl() }}" class="table-user-item">
+        <div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
+        <div>{{ $user->name }}</div>
+    </a>
+@else
+    [ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
+@endif
\ No newline at end of file
diff --git a/resources/views/settings/recycle-bin/destroy.blade.php b/resources/views/settings/recycle-bin/destroy.blade.php
new file mode 100644 (file)
index 0000000..ab60349
--- /dev/null
@@ -0,0 +1,29 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'maintenance'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin_permanently_delete') }}</h2>
+            <p class="text-muted">{{ trans('settings.recycle_bin_destroy_confirm') }}</p>
+            <form action="{{ url('/settings/recycle-bin/' . $deletion->id) }}" method="post">
+                {!! method_field('DELETE') !!}
+                {!! csrf_field() !!}
+                <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                <button type="submit" class="button">{{ trans('common.delete_confirm') }}</button>
+            </form>
+
+            @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
+                <hr class="mt-m">
+                <h5>{{ trans('settings.recycle_bin_destroy_list') }}</h5>
+                @include('settings.recycle-bin.parts.deletable-entity-list', ['entity' => $deletion->deletable])
+            @endif
+
+        </div>
+
+    </div>
+@stop
diff --git a/resources/views/settings/recycle-bin/index.blade.php b/resources/views/settings/recycle-bin/index.blade.php
new file mode 100644 (file)
index 0000000..b31bf02
--- /dev/null
@@ -0,0 +1,112 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'maintenance'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+
+            <div class="grid half left-focus">
+                <div>
+                    <p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
+                </div>
+                <div class="text-right">
+                    <div component="dropdown" class="dropdown-container">
+                        <button refs="dropdown@toggle"
+                                type="button"
+                                class="button outline">{{ trans('settings.recycle_bin_empty') }} </button>
+                        <div refs="dropdown@menu" class="dropdown-menu">
+                            <p class="text-neg small px-m mb-xs">{{ trans('settings.recycle_bin_empty_confirm') }}</p>
+
+                            <form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
+                                {!! csrf_field() !!}
+                                <button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                            </form>
+                        </div>
+                    </div>
+
+                </div>
+            </div>
+
+
+            <hr class="mt-l mb-s">
+
+            {!! $deletions->links() !!}
+
+            <table class="table">
+                <tr>
+                    <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="5">
+                            <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
+                        </td>
+                    </tr>
+                @endif
+                @foreach($deletions as $deletion)
+                <tr>
+                    <td>
+                        <div class="table-entity-item">
+                            <span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
+                            <div class="text-{{ $deletion->deletable->getType() }}">
+                                {{ $deletion->deletable->name }}
+                            </div>
+                        </div>
+                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+                            <div class="mb-m"></div>
+                        @endif
+                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book)
+                            <div class="pl-xl block inline">
+                                <div class="text-chapter">
+                                    @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
+                                </div>
+                            </div>
+                        @endif
+                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+                        <div class="pl-xl block inline">
+                            <div class="text-page">
+                                @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
+                            </div>
+                        </div>
+                        @endif
+                    </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="{{ $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>
+                </tr>
+                @endforeach
+            </table>
+
+            {!! $deletions->links() !!}
+
+        </div>
+
+    </div>
+@stop
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
diff --git a/resources/views/settings/recycle-bin/parts/entity-display-item.blade.php b/resources/views/settings/recycle-bin/parts/entity-display-item.blade.php
new file mode 100644 (file)
index 0000000..d6633ed
--- /dev/null
@@ -0,0 +1,7 @@
+<?php $type = $entity->getType(); ?>
+<div class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item no-hover">
+    <span role="presentation" class="icon text-{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}}">@icon($type)</span>
+    <div class="content">
+        <div class="entity-list-item-name break-text">{{ $entity->name }}</div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/settings/recycle-bin/restore.blade.php b/resources/views/settings/recycle-bin/restore.blade.php
new file mode 100644 (file)
index 0000000..5268bf0
--- /dev/null
@@ -0,0 +1,39 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <div class="py-m">
+            @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="{{ $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>
+            </form>
+
+            @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
+                <hr class="mt-m">
+                <h5>{{ trans('settings.recycle_bin_restore_list') }}</h5>
+                <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>
+
+    </div>
+@stop
index df902133f3ee514858ae703df5e52b5ab10f934b..f2edfa1c573aa6c3b3749c343f819d107aef3fc4 100644 (file)
@@ -1,15 +1,15 @@
-@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')])
+            @include('settings.roles.parts.form', ['title' => trans('settings.role_create')])
         </form>
     </div>
 
index 4f40345df99947dec5de11fa2d44cf88dcf382e0..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">
@@ -19,7 +19,7 @@
                 @if($role->users->count() > 0)
                     <div class="form-group">
                         <p>{{ trans('settings.role_delete_users_assigned', ['userCount' => $role->users->count()]) }}</p>
-                        @include('form.role-select', ['options' => $roles, 'name' => 'migration_role_id'])
+                        @include('form.role-select', ['options' => $roles, 'name' => 'migrate_role_id'])
                     </div>
                 @endif
 
index 0f83bdb0becca1370a8a3085055cf376e2a0b1e3..e2018d3e9b72a07692a54ceeabb9b74c9e968b83 100644 (file)
@@ -1,15 +1,15 @@
-@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'])
+            @include('settings.roles.parts.form', ['model' => $role, 'title' => trans('settings.role_edit'), 'icon' => 'edit'])
         </form>
     </div>
 
index b5aa96911e36e699e893fed8d9a3c81d0689d6fa..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -1,259 +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' || config('auth.method') === 'openid')
-                    <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 class="grid half" permissions-table>
-            <div>
-                <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>
-            <div class="toggle-switch-list">
-                <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>
-                <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>
-            <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') || $currentUser->id == $user->id)
-                            <a href="{{ url("/settings/users/{$user->id}") }}">
-                                @endif
-                                {{ $user->name }}
-                                @if(userCan('users-manage') || $currentUser->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..3f7f8fd
--- /dev/null
@@ -0,0 +1,267 @@
+{!! 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="display_name">{{ trans('settings.role_name') }}</label>
+                    @include('form.text', ['name' => 'display_name'])
+                </div>
+                <div class="form-group">
+                    <label for="description">{{ trans('settings.role_desc') }}</label>
+                    @include('form.text', ['name' => 'description'])
+                </div>
+                <div class="form-group">
+                    @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced') ])
+                </div>
+
+                @if(in_array(config('auth.method'), ['ldap', 'saml2', 'openid']))
+                    <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.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>
+
+    <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(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>
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>
diff --git a/resources/views/shelves/grid-item.blade.php b/resources/views/shelves/grid-item.blade.php
deleted file mode 100644 (file)
index 25b35b9..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<a href="{{$shelf->getUrl()}}" class="bookshelf-grid-item grid-card"
-   data-entity-type="bookshelf" data-entity-id="{{$shelf->id}}">
-    <div class="bg-shelf featured-image-container-wrap">
-        <div class="featured-image-container" @if($shelf->cover) style="background-image: url('{{ $shelf->getBookCover() }}')"@endif>
-        </div>
-        @icon('bookshelf')
-    </div>
-    <div class="grid-card-content">
-        <h2>{{$shelf->getShortName(35)}}</h2>
-        @if(isset($shelf->searchSnippet))
-            <p class="text-muted">{!! $shelf->searchSnippet !!}</p>
-        @else
-            <p class="text-muted">{{ $shelf->getExcerpt(130) }}</p>
-        @endif
-    </div>
-    <div class="grid-card-footer text-muted text-small">
-        @icon('star')<span title="{{$shelf->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $shelf->created_at->diffForHumans()]) }}</span>
-        <br>
-        @icon('edit')<span title="{{ $shelf->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $shelf->updated_at->diffForHumans()]) }}</span>
-    </div>
-</a>
\ No newline at end of file
index 56b76f96f01855e7646d5371b0e372f25fc10c8a..5c25356b084bbe6bda1c666ebd04f0f0225464ff 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')
@@ -9,13 +9,13 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @if($currentUser->can('bookshelf-create-all'))
+            @if(userCan('bookshelf-create-all'))
                 <a href="{{ url("/create-shelf") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <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'])
         </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 +41,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 82%
rename from resources/views/shelves/list-item.blade.php
rename to resources/views/shelves/parts/list-item.blade.php
index 6e5ed29a5fa41ef09156e58456b842e77b4039de..00cacfa707c3c28f927695e7cf7fc71c3f629ba1 100644 (file)
@@ -1,5 +1,5 @@
 <a href="{{ $shelf->getUrl() }}" class="shelf entity-list-item" data-entity-type="bookshelf" data-entity-id="{{$shelf->id}}">
-    <div class="entity-list-item-image bg-shelf @if($shelf->image_id) has-image @endif" style="background-image: url('{{ $shelf->getBookCover() }}')">
+    <div class="entity-list-item-image bg-bookshelf @if($shelf->image_id) has-image @endif" style="background-image: url('{{ $shelf->getBookCover() }}')">
         @icon('bookshelf')
     </div>
     <div class="content py-xs">
similarity index 83%
rename from resources/views/shelves/list.blade.php
rename to resources/views/shelves/parts/list.blade.php
index b20b08a2c59e40f8a110394404f63ac198c91f8f..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('shelves.grid-item', ['shelf' => $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 6fee6f45d522718ac0829dd7b9cf33e4a9b2874d..0d592468d1b8a1adace0bc1776695a779a4bd13a 100644 (file)
@@ -1,28 +1,48 @@
-@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>
 
     <main class="card content-wrap">
-        <h1 class="break-text">{{$shelf->name}}</h1>
+
+        <div class="flex-container-row wrap v-center">
+            <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('entities.sort', ['options' => [
+                    'default' => trans('common.sort_default'),
+                    'name' => trans('common.sort_name'),
+                    'created_at' => trans('common.sort_created_at'),
+                    'updated_at' => trans('common.sort_updated_at'),
+                ], 'order' => $order, 'sort' => $sort, 'type' => 'shelf_books'])
+            </div>
+        </div>
+
         <div class="book-content">
             <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
-            @if(count($shelf->visibleBooks) > 0)
+            @if(count($sortedVisibleShelfBooks) > 0)
                 @if($view === 'list')
                     <div class="entity-list">
-                        @foreach($shelf->visibleBooks as $book)
-                            @include('books.list-item', ['book' => $book])
+                        @foreach($sortedVisibleShelfBooks as $book)
+                            @include('books.parts.list-item', ['book' => $book])
                         @endforeach
                     </div>
                 @else
                     <div class="grid third">
-                        @foreach($shelf->visibleBooks as $key => $book)
-                            @include('books.grid-item', ['book' => $book])
+                        @foreach($sortedVisibleShelfBooks as $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))
@@ -78,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/tri-layout.blade.php b/resources/views/tri-layout.blade.php
deleted file mode 100644 (file)
index 71c5469..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-@extends('base')
-
-@section('body-class', 'tri-layout')
-
-@section('content')
-
-    <div class="tri-layout-mobile-tabs text-primary print-hidden">
-        <div class="grid half no-break no-gap">
-            <div class="tri-layout-mobile-tab px-m py-s" tri-layout-mobile-tab="info">
-                {{ trans('common.tab_info') }}
-            </div>
-            <div class="tri-layout-mobile-tab px-m py-s active" tri-layout-mobile-tab="content">
-                {{ trans('common.tab_content') }}
-            </div>
-        </div>
-    </div>
-
-    <div class="tri-layout-container" tri-layout @yield('container-attrs') >
-
-        <div class="tri-layout-left print-hidden pt-m" id="sidebar">
-            <aside class="tri-layout-left-contents">
-                @yield('left')
-            </aside>
-        </div>
-
-        <div class="@yield('body-wrap-classes') tri-layout-middle">
-            <div class="tri-layout-middle-contents">
-                @yield('body')
-            </div>
-        </div>
-
-        <div class="tri-layout-right print-hidden pt-m">
-            <aside class="tri-layout-right-contents">
-                @yield('right')
-            </aside>
-        </div>
-    </div>
-
-@stop
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 9971eeeeb54ca63ba42045982f076e6324936989..960e38f7c077e43ec7b76b137a63363930e139f6 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">
                 {!! csrf_field() !!}
 
                 <div class="setting-list">
-                    @include('users.form')
+                    @include('users.parts.form')
                 </div>
 
                 <div class="form-group text-right">
-                    <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
 
index d3349c2f3fc29b93e6b10319e95d0cefc97df7e5..aea4d7954543b3a138f88bf6fb0ce56de0f2300d 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>
+                </div>
+                <div>
+                    @include('form.user-select', ['name' => 'new_owner_id', 'user' => null, 'compact' => false])
+                </div>
+            </div>
+
+            <hr class="my-l">
+
             <div class="grid half">
                 <p class="text-neg"><strong>{{ trans('settings.users_delete_confirm') }}</strong></p>
                 <div>
index f78c25cebf6a93a00a82975b8ba09c308505a730..997fd1bf0e523aad6a4145f18b8fbe41b6094863 100644 (file)
@@ -1,20 +1,20 @@
-@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">
-            <h1 class="list-heading">{{ $user->id === $currentUser->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
+            <h1 class="list-heading">{{ $user->id === user()->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
             <form action="{{ url("/settings/users/{$user->id}") }}" method="post" enctype="multipart/form-data">
                 {!! csrf_field() !!}
                 <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,
@@ -54,7 +54,7 @@
                 </div>
 
                 <div class="text-right">
-                    <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     @if($authMethod !== 'system')
                         <a href="{{ url("/settings/users/{$user->id}/delete") }}" class="button outline">{{ trans('settings.users_delete') }}</a>
                     @endif
             </form>
         </section>
 
-        @if($currentUser->id === $user->id && count($activeSocialDrivers) > 0)
+        <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>
                 <p class="text-muted">{{ trans('settings.users_social_accounts_info') }}</p>
                                 <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>
             </section>
         @endif
 
-        @if(($currentUser->id === $user->id && userCan('access-api')) || userCan('users-manage'))
-            @include('users.api-tokens.list', ['user' => $user])
+        @if((user()->id === $user->id && userCan('access-api')) || userCan('users-manage'))
+            @include('users.api-tokens.parts.list', ['user' => $user])
         @endif
     </div>
 
index da373c1618b563fddcc9768644b7d1ac608e29cf..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 }}">
                             <input type="text" name="search" placeholder="{{ trans('settings.users_search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
                         </form>
                     </div>
-                    @if(userCan('users-manage'))
-                        <a href="{{ url("/settings/users/create") }}" style="margin-top: 0;" class="outline button">{{ trans('settings.users_add_new') }}</a>
-                    @endif
+                    <a href="{{ url("/settings/users/create") }}" class="outline button mt-none">{{ trans('settings.users_add_new') }}</a>
                 </div>
             </div>
 
-            {{--TODO - Add last login--}}
             <table class="table">
                 <tr>
                     <th></th>
                         <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'email']) }}">{{ trans('auth.email') }}</a>
                     </th>
                     <th>{{ trans('settings.role_user_roles') }}</th>
+                    <th class="text-right">
+                        <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'last_activity_at']) }}">{{ trans('settings.users_latest_activity') }}</a>
+                    </th>
                 </tr>
                 @foreach($users as $user)
                     <tr>
                         <td class="text-center" style="line-height: 0;"><img class="avatar med" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></td>
                         <td>
-                            @if(userCan('users-manage') || $currentUser->id == $user->id)
-                                <a href="{{ url("/settings/users/{$user->id}") }}">
-                                    @endif
-                                    {{ $user->name }} <br> <span class="text-muted">{{ $user->email }}</span>
-                                    @if(userCan('users-manage') || $currentUser->id == $user->id)
-                                </a>
-                            @endif
+                            <a href="{{ url("/settings/users/{$user->id}") }}">
+                                {{ $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>
                             @foreach($user->roles as $index => $role)
                                 <small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
                             @endforeach
                         </td>
+                        <td class="text-right text-muted">
+                            @if($user->last_activity_at)
+                                <small title="{{ $user->last_activity_at->format('Y-m-d H:i:s') }}">{{ $user->last_activity_at->diffForHumans() }}</small>
+                            @endif
+                        </td>
                     </tr>
                 @endforeach
             </table>
similarity index 94%
rename from resources/views/users/form.blade.php
rename to resources/views/users/parts/form.blade.php
index f3e8cedff7f4fb151a908669efab605a26226ca5..ef8c611ef0610ed6ff674a5021d82510a9bfbb5c 100644 (file)
@@ -25,7 +25,7 @@
     </div>
 </div>
 
-@if(($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') && userCan('users-manage'))
+@if(in_array($authMethod, ['ldap', 'saml2', 'openid']) && 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')
@@ -74,7 +74,7 @@
             <div class="grid half mt-m gap-xl">
                 <div>
                     <label for="password">{{ trans('auth.password') }}</label>
-                    @include('form.password', ['name' => 'password'])
+                    @include('form.password', ['name' => 'password', 'autocomplete' => 'new-password'])
                 </div>
                 <div>
                     <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
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 1b90d9b8fd12d591cf87b6e34fba2191b6711ff9..83a411219833ae3daa9048cb21e88ba1d539f29e 100644 (file)
@@ -3,10 +3,8 @@
 /**
  * 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', 'ApiDocsController@display');
 Route::get('docs.json', 'ApiDocsController@json');
 
 Route::get('books', 'BookApiController@list');
@@ -18,6 +16,7 @@ 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('books/{id}/export/markdown', 'BookExportApiController@exportMarkdown');
 
 Route::get('chapters', 'ChapterApiController@list');
 Route::post('chapters', 'ChapterApiController@create');
@@ -28,6 +27,18 @@ 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('chapters/{id}/export/markdown', 'ChapterExportApiController@exportMarkdown');
+
+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('pages/{id}/export/markdown', 'PageExportApiController@exportMarkDown');
 
 Route::get('shelves', 'BookshelfApiController@list');
 Route::post('shelves', 'BookshelfApiController@create');
index a47080e8e1516906a4ade8c294afe939fd3b4098..fb4282539d24360d4b2426cae1455c6b3f5a5355 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
-Route::get('/robots.txt', 'HomeController@getRobots');
+Route::get('/status', 'StatusController@show');
+Route::get('/robots.txt', 'HomeController@robots');
 
 // Authenticated routes...
 Route::group(['middleware' => 'auth'], function () {
@@ -9,11 +10,14 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
         ->where('path', '.*$');
 
+    // API docs routes
+    Route::get('/api/docs', 'Api\ApiDocsController@display');
+
     Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
 
     // Shelves
     Route::get('/create-shelf', 'BookshelfController@create');
-    Route::group(['prefix' => 'shelves'], function() {
+    Route::group(['prefix' => 'shelves'], function () {
         Route::get('/', 'BookshelfController@index');
         Route::post('/', 'BookshelfController@store');
         Route::get('/{slug}/edit', 'BookshelfController@edit');
@@ -47,6 +51,8 @@ Route::group(['middleware' => 'auth'], function () {
         Route::put('/{bookSlug}/sort', 'BookSortController@update');
         Route::get('/{bookSlug}/export/html', 'BookExportController@html');
         Route::get('/{bookSlug}/export/pdf', 'BookExportController@pdf');
+        Route::get('/{bookSlug}/export/markdown', 'BookExportController@markdown');
+        Route::get('/{bookSlug}/export/zip', 'BookExportController@zip');
         Route::get('/{bookSlug}/export/plaintext', 'BookExportController@plainText');
 
         // Pages
@@ -57,6 +63,7 @@ Route::group(['middleware' => 'auth'], function () {
         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/markdown', 'PageExportController@markdown');
         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');
@@ -91,6 +98,7 @@ Route::group(['middleware' => 'auth'], function () {
         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/markdown', 'ChapterExportController@markdown');
         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');
@@ -98,25 +106,17 @@ Route::group(['middleware' => 'auth'], function () {
     });
 
     // User Profile routes
-    Route::get('/user/{userId}', 'UserController@showProfilePage');
+    Route::get('/user/{slug}', 'UserProfileController@show');
 
     // Image routes
-    Route::group(['prefix' => 'images'], function () {
-
-        // Gallery
-        Route::get('/gallery', 'Images\GalleryImageController@list');
-        Route::post('/gallery', 'Images\GalleryImageController@create');
-
-        // Drawio
-        Route::get('/drawio', 'Images\DrawioImageController@list');
-        Route::get('/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64');
-        Route::post('/drawio', 'Images\DrawioImageController@create');
-
-        // Shared gallery & draw.io endpoint
-        Route::get('/usage/{id}', 'Images\ImageController@usage');
-        Route::put('/{id}', 'Images\ImageController@update');
-        Route::delete('/{id}', 'Images\ImageController@destroy');
-    });
+    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');
 
     // Attachments routes
     Route::get('/attachments/{id}', 'AttachmentController@get');
@@ -124,6 +124,7 @@ Route::group(['middleware' => 'auth'], function () {
     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');
@@ -142,9 +143,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
 
     // Comments
-    Route::post('/ajax/page/{pageId}/comment', 'CommentController@savePageComment');
-    Route::put('/ajax/comment/{id}', 'CommentController@update');
-    Route::delete('/ajax/comment/{id}', 'CommentController@destroy');
+    Route::post('/comment/{pageId}', 'CommentController@savePageComment');
+    Route::put('/comment/{id}', 'CommentController@update');
+    Route::delete('/comment/{id}', 'CommentController@destroy');
 
     // Links
     Route::get('/link/{id}', 'PageController@redirectFromLink');
@@ -155,23 +156,43 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter');
     Route::get('/search/entity/siblings', 'SearchController@searchSiblings');
 
+    // User Search
+    Route::get('/search/users/select', 'UserSearchController@forSelect');
+
+    // Template System
     Route::get('/templates', 'PageTemplateController@list');
     Route::get('/templates/{templateId}', 'PageTemplateController@get');
 
+    // Favourites
+    Route::get('/favourites', 'FavouriteController@index');
+    Route::post('/favourites/add', 'FavouriteController@add');
+    Route::post('/favourites/remove', 'FavouriteController@remove');
+
     // Other Pages
     Route::get('/', 'HomeController@index');
     Route::get('/home', 'HomeController@index');
     Route::get('/custom-head-content', 'HomeController@customHeadContent');
 
     // Settings
-    Route::group(['prefix' => 'settings'], function() {
+    Route::group(['prefix' => 'settings'], function () {
         Route::get('/', 'SettingController@index')->name('settings');
         Route::post('/', 'SettingController@update');
 
         // Maintenance
-        Route::get('/maintenance', 'SettingController@showMaintenance');
-        Route::delete('/maintenance/cleanup-images', 'SettingController@cleanupImages');
-        Route::post('/maintenance/send-test-email', 'SettingController@sendTestEmail');
+        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');
@@ -197,24 +218,36 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy');
 
         // Roles
-        Route::get('/roles', 'PermissionController@listRoles');
-        Route::get('/roles/new', 'PermissionController@createRole');
-        Route::post('/roles/new', 'PermissionController@storeRole');
-        Route::get('/roles/delete/{id}', 'PermissionController@showDeleteRole');
-        Route::delete('/roles/delete/{id}', 'PermissionController@deleteRole');
-        Route::get('/roles/{id}', 'PermissionController@editRole');
-        Route::put('/roles/{id}', 'PermissionController@updateRole');
+        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');
     });
+});
 
+// MFA routes
+Route::group(['middleware' => 'mfa-setup'], function () {
+    Route::get('/mfa/setup', 'Auth\MfaController@setup');
+    Route::get('/mfa/totp/generate', 'Auth\MfaTotpController@generate');
+    Route::post('/mfa/totp/confirm', 'Auth\MfaTotpController@confirm');
+    Route::get('/mfa/backup_codes/generate', 'Auth\MfaBackupCodesController@generate');
+    Route::post('/mfa/backup_codes/confirm', 'Auth\MfaBackupCodesController@confirm');
 });
+Route::group(['middleware' => 'guest'], function () {
+    Route::get('/mfa/verify', 'Auth\MfaController@verify');
+    Route::post('/mfa/totp/verify', 'Auth\MfaTotpController@verify');
+    Route::post('/mfa/backup_codes/verify', 'Auth\MfaBackupCodesController@verify');
+});
+Route::delete('/mfa/{method}/remove', 'Auth\MfaController@remove')->middleware('auth');
 
 // Social auth routes
-Route::get('/login/service/{socialDriver}', 'Auth\SocialController@getSocialLogin');
-Route::get('/login/service/{socialDriver}/callback', 'Auth\SocialController@socialCallback');
-Route::group(['middleware' => 'auth'], function () {
-    Route::get('/login/service/{socialDriver}/detach', 'Auth\SocialController@detachSocialAccount');
-});
-Route::get('/register/service/{socialDriver}', 'Auth\SocialController@socialRegister');
+Route::get('/login/service/{socialDriver}', 'Auth\SocialController@login');
+Route::get('/login/service/{socialDriver}/callback', 'Auth\SocialController@callback');
+Route::post('/login/service/{socialDriver}/detach', 'Auth\SocialController@detach')->middleware('auth');
+Route::get('/register/service/{socialDriver}', 'Auth\SocialController@register');
 
 // Login/Logout routes
 Route::get('/login', 'Auth\LoginController@getLogin');
@@ -251,4 +284,4 @@ Route::post('/password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail
 Route::get('/password/reset/{token}', 'Auth\ResetPasswordController@showResetForm');
 Route::post('/password/reset', 'Auth\ResetPasswordController@reset');
 
-Route::fallback('HomeController@getNotFound');
\ No newline at end of file
+Route::fallback('HomeController@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/tests/ActivityTrackingTest.php b/tests/ActivityTrackingTest.php
deleted file mode 100644 (file)
index f47bc44..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php namespace Tests;
-
-
-use BookStack\Entities\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..c45bd77eebcf4ccccb4af0ccd4badd224db2b5d9 100644 (file)
@@ -1,4 +1,6 @@
-<?php namespace Tests\Api;
+<?php
+
+namespace Tests\Api;
 
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\User;
@@ -29,28 +31,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 +67,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 +88,7 @@ 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_token_expiry_checked()
@@ -102,7 +104,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 +118,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 +143,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..062adce5376821a8b1914b734039c107edf6fa2b 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,16 +10,6 @@ class ApiDocsTest extends TestCase
 
     protected $endpoint = '/api/docs';
 
-    public function test_docs_page_not_visible_to_normal_viewers()
-    {
-        $viewer = $this->getViewer();
-        $resp = $this->actingAs($viewer)->get($this->endpoint);
-        $resp->assertStatus(403);
-
-        $resp = $this->actingAsApiEditor()->get($this->endpoint);
-        $resp->assertStatus(200);
-    }
-
     public function test_docs_page_returns_view_with_docs_content()
     {
         $resp = $this->actingAsApiEditor()->get($this->endpoint);
@@ -34,25 +25,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 bb4920cc3667d35d5c52ec3df904515a817cd602..f90ec5a3dd2a30fc806ca620e36919f6be11390e 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests\Api;
+<?php
 
-use BookStack\Entities\Book;
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
 use Tests\TestCase;
 
 class ApiListingTest extends 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
+}
index 3fd763ec625969872d9fa5a5f27f27e6002ad80e..91e2db9e52de5c4cf7bddd0df659e6cdc79c42c8 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests\Api;
+<?php
 
-use BookStack\Entities\Book;
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
 use Tests\TestCase;
 
 class BooksApiTest extends 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,14 +70,17 @@ 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,
             ],
             'updated_by' => [
                 'name' => $book->createdBy->name,
-            ]
+            ],
+            'owned_by' => [
+                'name' => $book->ownedBy->name,
+            ],
         ]);
     }
 
@@ -84,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',
         ];
 
@@ -137,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 15a44459ee3b4750a9e59e615224574fc7866299..c9ed1a2892e19a715dd344e4a4ac8e6b5302b41a 100644 (file)
@@ -1,7 +1,9 @@
-<?php namespace Tests\Api;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
 use Tests\TestCase;
 
 class ChaptersApiTest extends TestCase
@@ -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,21 +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,
+            ],
             'pages' => [
                 [
-                    'id' => $page->id,
+                    'id'   => $page->id,
                     'slug' => $page->slug,
                     'name' => $page->name,
-                ]
+                ],
             ],
         ]);
         $resp->assertJsonCount($chapter->pages()->count(), 'pages');
@@ -122,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',
-                ]
+                ],
             ],
         ];
 
@@ -137,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);
     }
@@ -183,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);
+        }
+    }
+}
diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php
new file mode 100644 (file)
index 0000000..4eb109d
--- /dev/null
@@ -0,0 +1,308 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PagesApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected $baseEndpoint = '/api/pages';
+
+    public function test_index_endpoint_returns_expected_page()
+    {
+        $this->actingAsApiEditor();
+        $firstPage = Page::query()->orderBy('id', 'asc')->first();
+
+        $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,
+                'priority' => $firstPage->priority,
+            ],
+        ]]);
+    }
+
+    public function test_create_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::query()->first();
+        $details = [
+            'name'    => 'My API page',
+            'book_id' => $book->id,
+            'html'    => '<p>My new page content</p>',
+            'tags'    => [
+                [
+                    'name'  => 'tagname',
+                    'value' => 'tagvalue',
+                ],
+            ],
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        unset($details['html']);
+        $resp->assertStatus(200);
+        $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_type' => $newItem->getMorphClass(),
+            'name'        => 'tagname',
+            'value'       => 'tagvalue',
+        ]);
+        $resp->assertSeeText('My new page content');
+        $resp->assertJsonMissing(['book' => []]);
+        $this->assertActivityExists('page_create', $newItem);
+    }
+
+    public function test_page_name_needed_to_create()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::query()->first();
+        $details = [
+            'book_id' => $book->id,
+            '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.'],
+        ]));
+    }
+
+    public function test_book_id_or_chapter_id_needed_to_create()
+    {
+        $this->actingAsApiEditor();
+        $details = [
+            'name' => 'My api page',
+            'html' => '<p>A page created via the API</p>',
+        ];
+
+        $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.'],
+        ]));
+
+        $chapter = Chapter::visible()->first();
+        $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['chapter_id' => $chapter->id]));
+        $resp->assertStatus(200);
+
+        $book = Book::visible()->first();
+        $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['book_id' => $book->id]));
+        $resp->assertStatus(200);
+    }
+
+    public function test_markdown_can_be_provided_for_create()
+    {
+        $this->actingAsApiEditor();
+        $book = Book::visible()->first();
+        $details = [
+            'book_id'  => $book->id,
+            'name'     => 'My api page',
+            'markdown' => "# A new API page \n[link](https://example.com)",
+        ];
+
+        $resp = $this->postJson($this->baseEndpoint, $details);
+        $resp->assertJson(['markdown' => $details['markdown']]);
+
+        $respHtml = $resp->json('html');
+        $this->assertStringContainsString('new API page</h1>', $respHtml);
+        $this->assertStringContainsString('link</a>', $respHtml);
+        $this->assertStringContainsString('href="https://example.com"', $respHtml);
+    }
+
+    public function test_read_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'id'         => $page->id,
+            'slug'       => $page->slug,
+            'created_by' => [
+                'name' => $page->createdBy->name,
+            ],
+            'book_id'    => $page->book_id,
+            'updated_by' => [
+                'name' => $page->createdBy->name,
+            ],
+            'owned_by' => [
+                'name' => $page->ownedBy->name,
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_provides_rendered_html()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $page->html = "<p>testing</p><script>alert('danger')</script><h1>Hello</h1>";
+        $page->save();
+
+        $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
+        $html = $resp->json('html');
+        $this->assertStringNotContainsString('script', $html);
+        $this->assertStringContainsString('Hello', $html);
+        $this->assertStringContainsString('testing', $html);
+    }
+
+    public function test_update_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $details = [
+            'name' => 'My updated API page',
+            'html' => '<p>A page created via the API</p>',
+            'tags' => [
+                [
+                    'name'  => 'freshtag',
+                    'value' => 'freshtagval',
+                ],
+            ],
+        ];
+
+        $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+        $page->refresh();
+
+        $resp->assertStatus(200);
+        unset($details['html']);
+        $resp->assertJson(array_merge($details, [
+            'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id,
+        ]));
+        $this->assertActivityExists('page_update', $page);
+    }
+
+    public function test_providing_new_chapter_id_on_update_will_move_page()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
+        $details = [
+            'name'       => 'My updated API page',
+            'chapter_id' => $chapter->id,
+            '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,
+        ]);
+    }
+
+    public function test_providing_move_via_update_requires_page_create_permission_on_new_parent()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+        $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
+        $this->setEntityRestrictions($chapter, ['view'], [$this->getEditor()->roles()->first()]);
+        $details = [
+            'name'       => 'My updated API page',
+            'chapter_id' => $chapter->id,
+            '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();
+        $page = Page::visible()->first();
+        $resp = $this->deleteJson($this->baseEndpoint . "/{$page->id}");
+
+        $resp->assertStatus(204);
+        $this->assertActivityExists('page_delete', $page);
+    }
+
+    public function test_export_html_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html");
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
+    }
+
+    public function test_export_plain_text_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext");
+        $resp->assertStatus(200);
+        $resp->assertSee($page->name);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
+    }
+
+    public function test_export_pdf_endpoint()
+    {
+        $this->actingAsApiEditor();
+        $page = Page::visible()->first();
+
+        $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf");
+        $resp->assertStatus(200);
+        $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
+    }
+
+    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);
+        }
+    }
+}
index 13e44d97de7d002317d2c598e107e584838b3088..8868c686e5086231fb26f5c7d2a7ce75e202c572 100644 (file)
@@ -1,7 +1,9 @@
-<?php namespace Tests\Api;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 use Tests\TestCase;
 
 class ShelvesApiTest extends TestCase
@@ -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,14 +80,17 @@ 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,
             ],
             'updated_by' => [
                 'name' => $shelf->createdBy->name,
-            ]
+            ],
+            'owned_by' => [
+                'name' => $shelf->ownedBy->name,
+            ],
         ]);
     }
 
@@ -94,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',
         ];
 
@@ -133,4 +138,4 @@ class ShelvesApiTest extends TestCase
         $resp->assertStatus(204);
         $this->assertActivityExists('bookshelf_delete');
     }
-}
\ No newline at end of file
+}
index 1ad4d14b64e4c7137134552848b1f7c800dbed6f..683ca0c747a022a31c829f9ea2359feadf4e6e44 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,7 @@ trait TestsApi
     protected function actingAsApiEditor()
     {
         $this->actingAs($this->getEditor(), 'api');
+
         return $this;
     }
 
@@ -20,7 +22,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 +31,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
+}
diff --git a/tests/AuditLogTest.php b/tests/AuditLogTest.php
new file mode 100644 (file)
index 0000000..8d13670
--- /dev/null
@@ -0,0 +1,192 @@
+<?php
+
+namespace Tests;
+
+use BookStack\Actions\Activity;
+use BookStack\Actions\ActivityService;
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\UserRepo;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\TrashCan;
+use Carbon\Carbon;
+
+class AuditLogTest extends TestCase
+{
+    /** @var ActivityService */
+    protected $activityService;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->activityService = app(ActivityService::class);
+    }
+
+    public function test_only_accessible_with_right_permissions()
+    {
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer);
+
+        $resp = $this->get('/settings/audit');
+        $this->assertPermissionError($resp);
+
+        $this->giveUserPermissions($viewer, ['settings-manage']);
+        $resp = $this->get('/settings/audit');
+        $this->assertPermissionError($resp);
+
+        $this->giveUserPermissions($viewer, ['users-manage']);
+        $resp = $this->get('/settings/audit');
+        $resp->assertStatus(200);
+        $resp->assertSeeText('Audit Log');
+    }
+
+    public function test_shows_activity()
+    {
+        $admin = $this->getAdmin();
+        $this->actingAs($admin);
+        $page = Page::query()->first();
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+        $activity = Activity::query()->orderBy('id', 'desc')->first();
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText($page->name);
+        $resp->assertSeeText('page_create');
+        $resp->assertSeeText($activity->created_at->toDateTimeString());
+        $resp->assertElementContains('.table-user-item', $admin->name);
+    }
+
+    public function test_shows_name_for_deleted_items()
+    {
+        $this->actingAs($this->getAdmin());
+        $page = Page::query()->first();
+        $pageName = $page->name;
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+
+        app(PageRepo::class)->destroy($page);
+        app(TrashCan::class)->empty();
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText('Deleted Item');
+        $resp->assertSeeText('Name: ' . $pageName);
+    }
+
+    public function test_shows_activity_for_deleted_users()
+    {
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer);
+        $page = Page::query()->first();
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+
+        $this->actingAs($this->getAdmin());
+        app(UserRepo::class)->destroy($viewer);
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText("[ID: {$viewer->id}] Deleted User");
+    }
+
+    public function test_filters_by_key()
+    {
+        $this->actingAs($this->getAdmin());
+        $page = Page::query()->first();
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText($page->name);
+
+        $resp = $this->get('settings/audit?event=page_delete');
+        $resp->assertDontSeeText($page->name);
+    }
+
+    public function test_date_filters()
+    {
+        $this->actingAs($this->getAdmin());
+        $page = Page::query()->first();
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+
+        $yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
+        $tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
+
+        $resp = $this->get('settings/audit?date_from=' . $yesterday);
+        $resp->assertSeeText($page->name);
+
+        $resp = $this->get('settings/audit?date_from=' . $tomorrow);
+        $resp->assertDontSeeText($page->name);
+
+        $resp = $this->get('settings/audit?date_to=' . $tomorrow);
+        $resp->assertSeeText($page->name);
+
+        $resp = $this->get('settings/audit?date_to=' . $yesterday);
+        $resp->assertDontSeeText($page->name);
+    }
+
+    public function test_user_filter()
+    {
+        $admin = $this->getAdmin();
+        $editor = $this->getEditor();
+        $this->actingAs($admin);
+        $page = Page::query()->first();
+        $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
+
+        $this->actingAs($editor);
+        $chapter = Chapter::query()->first();
+        $this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
+
+        $resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id);
+        $resp->assertSeeText($page->name);
+        $resp->assertDontSeeText($chapter->name);
+
+        $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_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,
+        ]);
+    }
+}
index 779d5e70fc228ba3c631c014160b8e01347c4ce5..1ffcc0815097bea1d78c1a5556292af5032f4d45 100644 (file)
@@ -1,48 +1,43 @@
-<?php namespace Tests\Auth;
+<?php
 
-use BookStack\Auth\Role;
+namespace Tests\Auth;
+
+use BookStack\Auth\Access\Mfa\MfaSession;
 use BookStack\Auth\User;
-use BookStack\Entities\Page;
+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 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()
@@ -52,15 +47,17 @@ class AuthTest extends BrowserKitTest
         $user = factory(User::class)->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]);
     }
 
     public function test_empty_registration_redirects_back_with_errors()
@@ -69,36 +66,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()
@@ -111,274 +105,211 @@ class AuthTest extends BrowserKitTest
         $user = factory(User::class)->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('/');
+        $this->get('/')->assertSee($user->name);
+        $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();
+
         // 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('/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();
+
         // 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('/login')
-            ->type($user->email, '#email')
-            ->type($user->password, '#password')
-            ->press('Log In')
-            ->seePageIs('/register/confirm/awaiting')
-            ->seeText('Email Address Not Confirmed');
-    }
-
-    public function test_user_creation()
-    {
-        $user = factory(User::class)->make();
+        $this->post('/register', $user->only('name', 'email', 'password'))
+            ->assertRedirect('/register/confirm');
+        $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
 
-        $this->asAdmin()
-            ->visit('/settings/users')
-            ->click('Add New User')
-            ->type($user->name, '#name')
-            ->type($user->email, '#email')
-            ->check('roles[admin]')
-            ->type($user->password, '#password')
-            ->type($user->password, '#password-confirm')
-            ->press('Save')
-            ->seePageIs('/settings/users')
-            ->seeInDatabase('users', $user->toArray())
-            ->see($user->name);
-    }
+        $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]);
+        $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_logout()
     {
-        $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->asAdmin()->get('/')->assertOk();
+        $this->get('/logout')->assertRedirect('/');
+        $this->get('/')->assertRedirect('/login');
     }
 
-    public function test_user_deletion()
+    public function test_mfa_session_cleared_on_logout()
     {
-        $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]);
-    }
+        $user = $this->getEditor();
+        $mfaSession = $this->app->make(MfaSession::class);
 
-    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) {
-            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');
-    }
+        $mfaSession->markVerifiedForUser($user);
+        $this->assertTrue($mfaSession->isVerifiedForUser($user));
 
-    public function test_logout()
-    {
-        $this->asAdmin()
-            ->visit('/')
-            ->seePageIs('/')
-            ->visit('/logout')
-            ->visit('/')
-            ->seePageIs('/login');
+        $this->asAdmin()->get('/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');
+
+        $resp = $this->post('/password/email', [
+            'email' => '[email protected]',
+        ]);
+        $resp->assertRedirect('/password/email');
+
+        $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->seeInDatabase('password_resets', [
-            'email' => '[email protected]'
+        $this->assertDatabaseHas('password_resets', [
+            'email' => '[email protected]',
         ]);
 
-        $user = User::where('email', '=', '[email protected]')->first();
+        /** @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_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()
+    {
+        config()->set('app.url', 'http://localhost');
+        $this->setSettings(['app-public' => true]);
+
+        $this->get('/login', ['referer' => 'https://example.com']);
+        $login = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
+
+        $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()
@@ -403,14 +334,37 @@ class AuthTest extends BrowserKitTest
         $this->assertFalse(auth('openid')->check());
     }
 
+    public function test_failed_logins_are_logged_when_message_configured()
+    {
+        $log = $this->withTestLogger();
+        config()->set(['logging.failed_login.message' => 'Failed login for %u']);
+
+        $this->post('/login', ['email' => '[email protected]', 'password' => 'cattreedog']);
+        $this->assertTrue($log->hasWarningThatContains('Failed login for [email protected]'));
+
+        $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
+        $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 ed8748f08ddff85a32304b61a5680d7b8cc36aa8..9e0729a8e9fc4d278f83b2db67daafc449dd83a6 100644 (file)
@@ -1,15 +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 Mockery\MockInterface;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
+use Tests\TestResponse;
 
-class LdapTest extends BrowserKitTest
+class LdapTest extends TestCase
 {
-
     /**
      * @var MockInterface
      */
@@ -21,46 +23,56 @@ class LdapTest extends BrowserKitTest
     public 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();
     }
 
+    protected function runFailedAuthLogin()
+    {
+        $this->commonLdapMocks(1, 1, 1, 1, 1);
+        $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
+            ->andReturn(['count' => 0]);
+        $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
+    }
+
     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] : []));
     }
 
     /**
@@ -83,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);
@@ -108,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()
@@ -133,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()
@@ -153,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()
@@ -173,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()
@@ -190,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(['name' => 'ldaptester', 'display_name' => 'LdapTester']);
-        $roleToReceive2 = factory(Role::class)->create(['name' => 'ldaptester-second', 'display_name' => 'LdapTester Second']);
-        $existingRole = factory(Role::class)->create(['name' => 'ldaptester-existing']);
+        $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']);
         $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,
         ]);
 
@@ -253,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(['name' => 'ldaptester', 'display_name' => 'LdapTester']);
-        $existingRole = factory(Role::class)->create(['name' => 'ldaptester-existing']);
+        $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
+        $existingRole = factory(Role::class)->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,
         ]);
 
@@ -298,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(['name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
-        $this->asAdmin()->visit('/settings/roles/' . $role->id)
-            ->see('ex-auth-a');
+        $role = factory(Role::class)->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(['name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
-        $roleToNotReceive = factory(Role::class)->create(['name' => 'ldaptester-not-receive', 'display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
+        $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']);
 
         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,
         ]);
 
@@ -343,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(['name' => 'ldaptester', 'display_name' => 'LdapTester']);
-        $roleToReceive2 = factory(Role::class)->create(['name' => 'ldaptester-second', 'display_name' => 'LdapTester Second']);
+        $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
+        $roleToReceive2 = factory(Role::class)->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,
         ]);
 
@@ -384,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);
@@ -445,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()
@@ -536,20 +566,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->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' => [],
         ]);
     }
 
+    public function test_start_tls_called_if_option_set()
+    {
+        config()->set(['services.ldap.start_tls' => true]);
+        $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true);
+        $this->runFailedAuthLogin();
+    }
+
+    public function test_connection_fails_if_tls_fails()
+    {
+        config()->set(['services.ldap.start_tls' => true]);
+        $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
+        $this->commonLdapMocks(1, 1, 0, 0, 0);
+        $resp = $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
+        $resp->assertStatus(500);
+    }
+
     public function test_ldap_attributes_can_be_binary_decoded_if_marked()
     {
         config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
@@ -559,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');
@@ -573,24 +619,103 @@ 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();
+        $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();
+        setting()->put('registration-confirmation', 'true');
+
+        app('config')->set([
+            'services.ldap.user_to_groups'     => true,
+            'services.ldap.group_attribute'    => 'memberOf',
+            'services.ldap.remove_from_groups' => true,
+        ]);
+
+        $this->commonLdapMocks(1, 1, 6, 8, 6, 4);
+        $this->mockLdap->shouldReceive('searchAndGetEntries')
+            ->times(6)
+            ->andReturn(['count' => 1, 0 => [
+                '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',
+                ],
+            ]]);
+
+        $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->assertDatabaseHas('role_user', [
+            'user_id' => $user->id,
+            'role_id' => $roleToReceive->id,
+        ]);
+
+        $this->assertNull(auth()->user());
+
+        $homePage = $this->get('/');
+        $homePage->assertRedirect('/login');
+
+        $login = $this->followingRedirects()->mockUserLogin();
+        $login->assertSee('Email Address Not Confirmed');
+    }
+
+    public function test_failed_logins_are_logged_when_message_configured()
+    {
+        $log = $this->withTestLogger();
+        config()->set(['logging.failed_login.message' => 'Failed login for %u']);
+        $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('/');
 
-        $this->see('A user with the email [email protected] already exists but with different credentials');
+        $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..685aad8
--- /dev/null
@@ -0,0 +1,167 @@
+<?php
+
+namespace Tests\Auth;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Access\Mfa\MfaValue;
+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(htmlentities("?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());
+    }
+}
diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php
new file mode 100644 (file)
index 0000000..ee6f3ec
--- /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"]');
+
+        $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];
+    }
+}
index d0da4529735adcb7e2dfe0229c768cac4552aa2e..8ace3e2ee4f19dd9ea8fee11f606f2225f601277 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
     {
         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,
         ]);
     }
 
@@ -67,25 +69,23 @@ class Saml2Test extends TestCase
         $this->assertFalse($this->isAuthenticated());
 
         $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-
             $acsPost = $this->post('/saml2/acs');
             $acsPost->assertRedirect('/');
             $this->assertTrue($this->isAuthenticated());
             $this->assertDatabaseHas('users', [
-                'email' => '[email protected]',
+                'email'            => '[email protected]',
                 'external_auth_id' => 'user',
-                'email_confirmed' => false,
-                'name' => 'Barry Scott'
+                'email_confirmed'  => false,
+                'name'             => 'Barry Scott',
             ]);
-
         });
     }
 
     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,
         ]);
 
@@ -105,8 +105,8 @@ class Saml2Test extends TestCase
     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,
         ]);
 
@@ -164,7 +164,7 @@ class Saml2Test extends TestCase
     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,
         ]);
 
@@ -182,7 +182,7 @@ class Saml2Test extends TestCase
     public function test_dump_user_details_option_works()
     {
         config()->set([
-            'saml2.onelogin.strict' => false,
+            'saml2.onelogin.strict'   => false,
             'saml2.dump_user_details' => true,
         ]);
 
@@ -190,30 +190,12 @@ class Saml2Test extends TestCase
             $acsPost = $this->post('/saml2/acs');
             $acsPost->assertJsonStructure([
                 'id_from_idp',
-                'attrs_from_idp' => [],
+                'attrs_from_idp'      => [],
                 'attrs_after_parsing' => [],
             ]);
         });
     }
 
-    public function test_user_registration_with_existing_email()
-    {
-        config()->set([
-            'saml2.onelogin.strict' => false,
-        ]);
-
-        $viewer = $this->getViewer();
-        $viewer->email = '[email protected]';
-        $viewer->save();
-
-        $this->withPost(['SAMLResponse' => $this->acsPostData], function () {
-            $acsPost = $this->post('/saml2/acs');
-            $acsPost->assertRedirect('/');
-            $errorMessage = session()->get('error');
-            $this->assertEquals('A user with the email [email protected] already exists but with different credentials.', $errorMessage);
-        });
-    }
-
     public function test_saml_routes_are_only_active_if_saml_enabled()
     {
         config()->set(['auth.method' => 'standard']);
@@ -275,7 +257,7 @@ 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,
@@ -290,6 +272,99 @@ class Saml2Test extends TestCase
         });
     }
 
+    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.remove_from_groups' => false,
+        ]);
+
+        $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']);
+        $adminRole = Role::getSystemRole('admin');
+
+        $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) {
+            $acsPost = $this->followingRedirects()->post('/saml2/acs');
+
+            $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->assertFalse(boolval($user->email_confirmed), 'User email remains unconfirmed');
+        });
+
+        $this->assertNull(auth()->user());
+        $homeGet = $this->get('/');
+        $homeGet->assertRedirect('/login');
+    }
+
+    public function test_login_where_existing_non_saml_user_shows_warning()
+    {
+        $this->post('/saml2/login');
+        config()->set(['saml2.onelogin.strict' => false]);
+
+        // Make the user pre-existing in DB with different auth_id
+        User::query()->forceCreate([
+            'email'            => '[email protected]',
+            'external_auth_id' => 'old_system_user_id',
+            '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');
+        });
+    }
+
+    public function test_login_request_contains_expected_default_authncontext()
+    {
+        $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);
+    }
+
+    public function test_false_idp_authncontext_option_does_not_pass_authncontext_in_saml_request()
+    {
+        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);
@@ -324,23 +399,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 d448b567e5ef1462bf168500bc2dc6b8ed1df2f2..f70263dd278ae1a8bf93efe26c896bda300d97e3 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,7 +13,6 @@ use Tests\TestCase;
 
 class SocialAuthTest extends TestCase
 {
-
     public function test_social_registration()
     {
         $user = factory(User::class)->make();
@@ -17,8 +20,7 @@ class SocialAuthTest extends TestCase
         $this->setSettings(['registration-enabled' => 'true']);
         config(['GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']);
 
-        $mockSocialite = Mockery::mock(Factory::class);
-        $this->app[Factory::class] = $mockSocialite;
+        $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
 
@@ -43,11 +45,10 @@ 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 = Mockery::mock(Factory::class);
-        $this->app[Factory::class] = $mockSocialite;
+        $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
 
@@ -61,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
@@ -71,30 +72,54 @@ 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();
-        $mockSocialite = Mockery::mock(Factory::class);
-        $this->app[Factory::class] = $mockSocialite;
+        $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
 
@@ -128,12 +153,11 @@ 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();
-        $mockSocialite = Mockery::mock(Factory::class);
-        $this->app[Factory::class] = $mockSocialite;
+        $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
 
@@ -169,8 +193,7 @@ class SocialAuthTest extends TestCase
         $this->setSettings(['registration-enabled' => 'true']);
         config(['GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']);
 
-        $mockSocialite = Mockery::mock(Factory::class);
-        $this->app[Factory::class] = $mockSocialite;
+        $mockSocialite = $this->mock(Factory::class);
         $mockSocialDriver = Mockery::mock(Provider::class);
         $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
 
@@ -189,5 +212,4 @@ class SocialAuthTest extends TestCase
         $user = $user->whereEmail($user->email)->first();
         $this->assertDatabaseHas('social_accounts', ['user_id' => $user->id]);
     }
-
 }
index f2a1d0e78177e94330515b37eb924e19770c9c95..dcf9e23df9b829a10cfd175800d058247c1333fe 100644 (file)
@@ -1,34 +1,36 @@
-<?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\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();
         $admin = $this->getAdmin();
 
-        $this->actingAs($admin)->post('/settings/users/create', [
-            'name' => 'Barry',
-            'email' => '[email protected]',
+        $email = Str::random(16) . '@example.com';
+        $resp = $this->actingAs($admin)->post('/settings/users/create', [
+            'name'        => 'Barry',
+            'email'       => $email,
             'send_invite' => 'true',
         ]);
+        $resp->assertRedirect('/settings/users');
 
-        $newUser = User::query()->where('email', '=', '[email protected]')->orderBy('id', 'desc')->first();
+        $newUser = User::query()->where('email', '=', $email)->orderBy('id', 'desc')->first();
 
         Notification::assertSentTo($newUser, UserInvite::class);
         $this->assertDatabaseHas('user_invites', [
-            'user_id' => $newUser->id
+            'user_id' => $newUser->id,
         ]);
     }
 
@@ -52,12 +54,12 @@ class UserInviteTest extends TestCase
         ]);
         $setPasswordResp->assertSee('Password set, you now have access to 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,
         ]);
     }
 
@@ -83,7 +85,7 @@ class UserInviteTest extends TestCase
         $noPassword->assertSee('The password field is required.');
 
         $this->assertDatabaseHas('user_invites', [
-            'user_id' => $user->id
+            'user_id' => $user->id,
         ]);
     }
 
@@ -110,6 +112,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 b81afe3..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-<?php namespace Tests;
-
-use BookStack\Entities\Entity;
-use BookStack\Auth\Role;
-use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Settings\SettingService;
-use Illuminate\Contracts\Console\Kernel;
-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 \Illuminate\Foundation\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 \BookStack\Auth\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.
-     * @param $creatorUser
-     * @param $updaterUser
-     * @return array
-     */
-    protected function createEntityChainBelongingToUser($creatorUser, $updaterUser = false)
-    {
-        if ($updaterUser === false) $updaterUser = $creatorUser;
-        $book = factory(\BookStack\Entities\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
-        $chapter = factory(\BookStack\Entities\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]);
-        $page = factory(\BookStack\Entities\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]);
-        $restrictionService = $this->app[PermissionService::class];
-        $restrictionService->buildJointPermissionsForEntity($book);
-        return [
-            'book' => $book,
-            'chapter' => $chapter,
-            'page' => $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(\BookStack\Auth\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 ? 'assertNotRegExp' : 'assertRegExp';
-
-        $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
new file mode 100644 (file)
index 0000000..0f14424
--- /dev/null
@@ -0,0 +1,27 @@
+<?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');
+    }
+}
diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php
new file mode 100644 (file)
index 0000000..172e6c6
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class ClearActivityCommandTest extends TestCase
+{
+    public function test_clear_activity_command()
+    {
+        $this->asEditor();
+        $page = Page::first();
+        \Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
+
+        $this->assertDatabaseHas('activities', [
+            'type'      => 'page_update',
+            'entity_id' => $page->id,
+            'user_id'   => $this->getEditor()->id,
+        ]);
+
+        DB::rollBack();
+        $exitCode = \Artisan::call('bookstack:clear-activity');
+        DB::beginTransaction();
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('activities', [
+            'type' => 'page_update',
+        ]);
+    }
+}
diff --git a/tests/Commands/ClearRevisionsCommandTest.php b/tests/Commands/ClearRevisionsCommandTest.php
new file mode 100644 (file)
index 0000000..a7aef95
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use Illuminate\Support\Facades\Artisan;
+use Tests\TestCase;
+
+class ClearRevisionsCommandTest extends TestCase
+{
+    public function test_clear_revisions_command()
+    {
+        $this->asEditor();
+        $pageRepo = app(PageRepo::class);
+        $page = Page::first();
+        $pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+        $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '<p>new content in draft</p>', 'summary' => 'page revision testing']);
+
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'type'    => 'version',
+        ]);
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'type'    => 'update_draft',
+        ]);
+
+        $exitCode = Artisan::call('bookstack:clear-revisions');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('page_revisions', [
+            'page_id' => $page->id,
+            'type'    => 'version',
+        ]);
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'type'    => 'update_draft',
+        ]);
+
+        $exitCode = Artisan::call('bookstack:clear-revisions', ['--all' => true]);
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('page_revisions', [
+            'page_id' => $page->id,
+            'type'    => 'update_draft',
+        ]);
+    }
+}
diff --git a/tests/Commands/ClearViewsCommandTest.php b/tests/Commands/ClearViewsCommandTest.php
new file mode 100644 (file)
index 0000000..bbd06fa
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class ClearViewsCommandTest extends TestCase
+{
+    public function test_clear_views_command()
+    {
+        $this->asEditor();
+        $page = Page::first();
+
+        $this->get($page->getUrl());
+
+        $this->assertDatabaseHas('views', [
+            'user_id'     => $this->getEditor()->id,
+            'viewable_id' => $page->id,
+            'views'       => 1,
+        ]);
+
+        DB::rollBack();
+        $exitCode = \Artisan::call('bookstack:clear-views');
+        DB::beginTransaction();
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('views', [
+            'user_id' => $this->getEditor()->id,
+        ]);
+    }
+}
diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php
new file mode 100644 (file)
index 0000000..5a60b8d
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Entities\Models\Bookshelf;
+use Tests\TestCase;
+
+class CopyShelfPermissionsCommandTest extends TestCase
+{
+    public function test_copy_shelf_permissions_command_shows_error_when_no_required_option_given()
+    {
+        $this->artisan('bookstack:copy-shelf-permissions')
+            ->expectsOutput('Either a --slug or --all option must be provided.')
+            ->assertExitCode(0);
+    }
+
+    public function test_copy_shelf_permissions_command_using_slug()
+    {
+        $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->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
+        $this->artisan('bookstack:copy-shelf-permissions', [
+            '--slug' => $shelf->slug,
+        ]);
+        $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->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_copy_shelf_permissions_command_using_all()
+    {
+        $shelf = Bookshelf::query()->first();
+        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->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->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]);
+    }
+}
diff --git a/tests/Commands/RegenerateCommentContentCommandTest.php b/tests/Commands/RegenerateCommentContentCommandTest.php
new file mode 100644 (file)
index 0000000..08f1377
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Actions\Comment;
+use Tests\TestCase;
+
+class RegenerateCommentContentCommandTest extends TestCase
+{
+    public function test_regenerate_comment_content_command()
+    {
+        Comment::query()->forceCreate([
+            'html' => 'some_old_content',
+            'text' => 'some_fresh_content',
+        ]);
+
+        $this->assertDatabaseHas('comments', [
+            'html' => 'some_old_content',
+        ]);
+
+        $exitCode = \Artisan::call('bookstack:regenerate-comment-content');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('comments', [
+            'html' => 'some_old_content',
+        ]);
+        $this->assertDatabaseHas('comments', [
+            'html' => "<p>some_fresh_content</p>\n",
+        ]);
+    }
+}
diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php
new file mode 100644 (file)
index 0000000..2090c99
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class RegeneratePermissionsCommandTest extends TestCase
+{
+    public function test_regen_permissions_command()
+    {
+        \DB::rollBack();
+        JointPermission::query()->truncate();
+        $page = Page::first();
+
+        $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);
+
+        $exitCode = \Artisan::call('bookstack:regenerate-permissions');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+        DB::beginTransaction();
+
+        $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]);
+    }
+}
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);
+    }
+}
diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php
new file mode 100644 (file)
index 0000000..0acccd8
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use Symfony\Component\Console\Exception\RuntimeException;
+use Tests\TestCase;
+
+class UpdateUrlCommandTest extends TestCase
+{
+    public function test_command_updates_page_content()
+    {
+        $page = Page::query()->first();
+        $page->html = '<a href="https://example.com/donkeys"></a>';
+        $page->save();
+
+        $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');
+
+        $this->assertDatabaseHas('pages', [
+            '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://';
+        $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);
+
+        $this->expectException(RuntimeException::class);
+        $this->artisan('bookstack:update-url https://cats.example.com');
+    }
+
+    public function test_command_updates_settings()
+    {
+        setting()->put('my-custom-item', 'https://example.com/donkey/cat');
+        $this->runUpdate('https://example.com', 'https://cats.example.com');
+
+        $settingVal = setting('my-custom-item');
+        $this->assertEquals('https://cats.example.com/donkey/cat', $settingVal);
+    }
+
+    public function test_command_updates_array_settings()
+    {
+        setting()->put('my-custom-array-item', [['name' => 'a https://example.com/donkey/cat url']]);
+        $this->runUpdate('https://example.com', 'https://cats.example.com');
+        $settingVal = setting('my-custom-array-item');
+        $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']);
+    }
+
+    protected function runUpdate(string $oldUrl, string $newUrl)
+    {
+        $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');
+    }
+}
diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php
deleted file mode 100644 (file)
index bfc0ac0..0000000
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php namespace Tests;
-
-use BookStack\Actions\Comment;
-use BookStack\Actions\CommentRepo;
-use BookStack\Auth\Permissions\JointPermission;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Page;
-use BookStack\Auth\User;
-use BookStack\Entities\Repos\PageRepo;
-use Symfony\Component\Console\Exception\RuntimeException;
-
-class CommandsTest extends TestCase
-{
-
-    public function test_clear_views_command()
-    {
-        $this->asEditor();
-        $page = Page::first();
-
-        $this->get($page->getUrl());
-
-        $this->assertDatabaseHas('views', [
-            'user_id' => $this->getEditor()->id,
-            'viewable_id' => $page->id,
-            'views' => 1
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-views');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('views', [
-            'user_id' => $this->getEditor()->id
-        ]);
-    }
-
-    public function test_clear_activity_command()
-    {
-        $this->asEditor();
-        $page = Page::first();
-        \Activity::add($page, 'page_update', $page->book->id);
-
-        $this->assertDatabaseHas('activities', [
-            'key' => 'page_update',
-            'entity_id' => $page->id,
-            'user_id' => $this->getEditor()->id
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-activity');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-
-        $this->assertDatabaseMissing('activities', [
-            'key' => 'page_update'
-        ]);
-    }
-
-    public function test_clear_revisions_command()
-    {
-        $this->asEditor();
-        $pageRepo = app(PageRepo::class);
-        $page = Page::first();
-        $pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
-        $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '<p>new content in draft</p>', 'summary' => 'page revision testing']);
-
-        $this->assertDatabaseHas('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'version'
-        ]);
-        $this->assertDatabaseHas('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'update_draft'
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-revisions');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'version'
-        ]);
-        $this->assertDatabaseHas('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'update_draft'
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-revisions', ['--all' => true]);
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'update_draft'
-        ]);
-    }
-
-    public function test_regen_permissions_command()
-    {
-        JointPermission::query()->truncate();
-        $page = Page::first();
-
-        $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);
-
-        $exitCode = \Artisan::call('bookstack:regenerate-permissions');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]);
-    }
-
-    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::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');
-    }
-
-    public function test_copy_shelf_permissions_command_shows_error_when_no_required_option_given()
-    {
-        $this->artisan('bookstack:copy-shelf-permissions')
-            ->expectsOutput('Either a --slug or --all option must be provided.')
-            ->assertExitCode(0);
-    }
-
-    public function test_copy_shelf_permissions_command_using_slug()
-    {
-        $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->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
-        $this->artisan('bookstack:copy-shelf-permissions', [
-            '--slug' => $shelf->slug,
-        ]);
-        $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->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_copy_shelf_permissions_command_using_all()
-    {
-        $shelf = Bookshelf::query()->first();
-        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->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->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_update_url_command_updates_page_content()
-    {
-        $page = Page::query()->first();
-        $page->html = '<a href="https://example.com/donkeys"></a>';
-        $page->save();
-
-        $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');
-
-        $this->assertDatabaseHas('pages', [
-            'id' => $page->id,
-            'html' => '<a href="https://cats.example.com/donkeys"></a>'
-        ]);
-    }
-
-    public function test_update_url_command_requires_valid_url()
-    {
-        $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);
-
-        $this->expectException(RuntimeException::class);
-        $this->artisan('bookstack:update-url https://cats.example.com');
-    }
-
-    public function test_regenerate_comment_content_command()
-    {
-        Comment::query()->forceCreate([
-            'html' => 'some_old_content',
-            'text' => 'some_fresh_content',
-        ]);
-
-        $this->assertDatabaseHas('comments', [
-            'html' => 'some_old_content',
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:regenerate-comment-content');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('comments', [
-            'html' => 'some_old_content',
-        ]);
-        $this->assertDatabaseHas('comments', [
-            'html' => "<p>some_fresh_content</p>\n",
-        ]);
-    }
-}
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
+}
index cb3acfb1e8eb8724d3a927e0b4c8c1a5a11d9823..1780ddee819504ebbd94bae50163653934a0198e 100644 (file)
@@ -1,8 +1,10 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
 use BookStack\Uploads\Image;
 use Illuminate\Support\Str;
 use Tests\TestCase;
@@ -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()
@@ -59,7 +60,7 @@ class BookShelfTest extends TestCase
     public function test_book_not_visible_in_shelf_list_view_if_user_cant_view_shelf()
     {
         config()->set([
-            'app.views.bookshelves' => 'list',
+            'setting-defaults.user.bookshelves_view_type' => 'list',
         ]);
         $shelf = Bookshelf::query()->first();
         $book = $shelf->books()->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);
@@ -156,6 +157,47 @@ class BookShelfTest extends TestCase
         $resp->assertDontSee($shelf->getUrl('/permissions'));
     }
 
+    public function test_shelf_view_has_sort_control_that_defaults_to_default()
+    {
+        $shelf = Bookshelf::query()->first();
+        $resp = $this->asAdmin()->get($shelf->getUrl());
+        $resp->assertElementExists('form[action$="change-sort/shelf_books"]');
+        $resp->assertElementContains('form[action$="change-sort/shelf_books"] [aria-haspopup="true"]', 'Default');
+    }
+
+    public function test_shelf_view_sort_takes_action()
+    {
+        $shelf = Bookshelf::query()->whereHas('books')->with('books')->first();
+        $books = Book::query()->take(3)->get(['id', 'name']);
+        $books[0]->fill(['name' => 'bsfsdfsdfsd'])->save();
+        $books[1]->fill(['name' => 'adsfsdfsdfsd'])->save();
+        $books[2]->fill(['name' => 'hdgfgdfg'])->save();
+
+        // Set book ordering
+        $this->asAdmin()->put($shelf->getUrl(), [
+            'books' => $books->implode('id', ','),
+            'tags'  => [], 'description' => 'abc', 'name' => 'abc',
+        ]);
+        $this->assertEquals(3, $shelf->books()->count());
+        $shelf->refresh();
+
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertElementContains('.book-content a.grid-card', $books[0]->name, 1);
+        $resp->assertElementNotContains('.book-content a.grid-card', $books[0]->name, 3);
+
+        setting()->putUser($this->getEditor(), 'shelf_books_sort_order', 'desc');
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertElementNotContains('.book-content a.grid-card', $books[0]->name, 1);
+        $resp->assertElementContains('.book-content a.grid-card', $books[0]->name, 3);
+
+        setting()->putUser($this->getEditor(), 'shelf_books_sort_order', 'desc');
+        setting()->putUser($this->getEditor(), 'shelf_books_sort', 'name');
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertElementContains('.book-content a.grid-card', 'hdgfgdfg', 1);
+        $resp->assertElementContains('.book-content a.grid-card', 'bsfsdfsdfsd', 2);
+        $resp->assertElementContains('.book-content a.grid-card', 'adsfsdfsdfsd', 3);
+    }
+
     public function test_shelf_edit()
     {
         $shelf = Bookshelf::first();
@@ -164,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);
@@ -205,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());
@@ -222,16 +264,25 @@ class BookShelfTest extends TestCase
 
     public function test_shelf_delete()
     {
-        $shelf = Bookshelf::first();
-        $resp = $this->asEditor()->get($shelf->getUrl('/delete'));
-        $resp->assertSeeText('Delete Bookshelf');
-        $resp->assertSee("action=\"{$shelf->getUrl()}\"");
-
-        $resp = $this->delete($shelf->getUrl());
-        $resp->assertRedirect('/shelves');
-        $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]);
-        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
-        $this->assertSessionHas('success');
+        $shelf = Bookshelf::query()->whereHas('books')->first();
+        $this->assertNull($shelf->deleted_at);
+        $bookCount = $shelf->books()->count();
+
+        $deleteViewReq = $this->asEditor()->get($shelf->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this bookshelf?');
+
+        $deleteReq = $this->delete($shelf->getUrl());
+        $deleteReq->assertRedirect(url('/shelves'));
+        $this->assertActivityExists('bookshelf_delete', $shelf);
+
+        $shelf->refresh();
+        $this->assertNotNull($shelf->deleted_at);
+
+        $this->assertTrue($shelf->books()->count() === $bookCount);
+        $this->assertTrue($shelf->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Bookshelf Successfully Deleted');
     }
 
     public function test_shelf_copy_permissions()
@@ -243,20 +294,27 @@ class BookShelfTest extends TestCase
 
         $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();
@@ -287,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);
@@ -296,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();
@@ -311,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');
+    }
 }
diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php
new file mode 100644 (file)
index 0000000..fa63c0b
--- /dev/null
@@ -0,0 +1,207 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class BookTest extends TestCase
+{
+    public function test_create()
+    {
+        $book = factory(Book::class)->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 = factory(Book::class)->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);
+        $pageCount = $book->pages()->count();
+        $chapterCount = $book->chapters()->count();
+
+        $deleteViewReq = $this->asEditor()->get($book->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this book?');
+
+        $deleteReq = $this->delete($book->getUrl());
+        $deleteReq->assertRedirect(url('/books'));
+        $this->assertActivityExists('book_delete', $book);
+
+        $book->refresh();
+        $this->assertNotNull($book->deleted_at);
+
+        $this->assertTrue($book->pages()->count() === 0);
+        $this->assertTrue($book->chapters()->count() === 0);
+        $this->assertTrue($book->pages()->withTrashed()->count() === $pageCount);
+        $this->assertTrue($book->chapters()->withTrashed()->count() === $chapterCount);
+        $this->assertTrue($book->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Book Successfully Deleted');
+    }
+
+    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('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);
+    }
+}
diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php
new file mode 100644 (file)
index 0000000..ea29ece
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use Tests\TestCase;
+
+class ChapterTest extends TestCase
+{
+    public function test_create()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $chapter = factory(Chapter::class)->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);
+        $pageCount = $chapter->pages()->count();
+
+        $deleteViewReq = $this->asEditor()->get($chapter->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this chapter?');
+
+        $deleteReq = $this->delete($chapter->getUrl());
+        $deleteReq->assertRedirect($chapter->getParent()->getUrl());
+        $this->assertActivityExists('chapter_delete', $chapter);
+
+        $chapter->refresh();
+        $this->assertNotNull($chapter->deleted_at);
+
+        $this->assertTrue($chapter->pages()->count() === 0);
+        $this->assertTrue($chapter->pages()->withTrashed()->count() === $pageCount);
+        $this->assertTrue($chapter->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $redirectReq->assertNotificationContains('Chapter Successfully Deleted');
+    }
+}
index 3c8cae68ccefefd1e28f0aca63884c28dfdf0b33..d8caa73585aebcd54dd085b87428878a3cd71d80 100644 (file)
@@ -1,35 +1,35 @@
-<?php namespace Tests\Entity;
+<?php
 
-use BookStack\Entities\Page;
-use Tests\BrowserKitTest;
+namespace Tests\Entity;
 
-class CommentSettingTest extends BrowserKitTest
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class CommentSettingTest extends TestCase
 {
     protected $page;
 
     public 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 2562f7e7de9e7aabfe5add97647b1dcee117064e..3bf51556e3cea5f87ee94b1d05c0560fc7abe69c 100644 (file)
@@ -1,19 +1,20 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
-use BookStack\Entities\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]);
-        $resp = $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes());
+        $resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $resp->assertStatus(200);
         $resp->assertSee($comment->text);
@@ -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,
         ]);
     }
 
@@ -36,11 +37,11 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $comment = factory(Comment::class)->make();
-        $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes());
+        $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
         $newText = 'updated text content';
-        $resp = $this->putJson("/ajax/comment/$comment->id", [
+        $resp = $this->putJson("/comment/$comment->id", [
             'text' => $newText,
         ]);
 
@@ -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,
         ]);
     }
 
@@ -60,30 +61,30 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $comment = factory(Comment::class)->make();
-        $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes());
+        $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
 
-        $resp = $this->delete("/ajax/comment/$comment->id");
+        $resp = $this->delete("/comment/$comment->id");
         $resp->assertStatus(200);
 
         $this->assertDatabaseMissing('comments', [
-            'id' => $comment->id
+            'id' => $comment->id,
         ]);
     }
 
     public function test_comments_converts_markdown_input_to_html()
     {
         $page = Page::first();
-        $this->asAdmin()->postJson("/ajax/page/$page->id/comment", [
+        $this->asAdmin()->postJson("/comment/$page->id", [
             'text' => '# My Title',
         ]);
 
         $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());
@@ -96,7 +97,7 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $script = '<script>const a = "script";</script>\n\n# sometextinthecomment';
-        $this->postJson("/ajax/page/$page->id/comment", [
+        $this->postJson("/comment/$page->id", [
             'text' => $script,
         ]);
 
@@ -105,7 +106,7 @@ class CommentTest extends TestCase
         $pageView->assertSee('sometextinthecomment');
 
         $comment = $page->comments()->first();
-        $this->putJson("/ajax/comment/$comment->id", [
+        $this->putJson("/comment/$comment->id", [
             'text' => $script . 'updated',
         ]);
 
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 956e46c3713d4785cd7b8dcd11589cc6988dcfb0..8d2ef0fded27bf40f86760b1c9f45b6e4c5c25a8 100644 (file)
@@ -1,15 +1,16 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
 use BookStack\Actions\Tag;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class EntitySearchTest extends TestCase
 {
-
     public function test_page_search()
     {
         $book = Book::all()->first();
@@ -80,13 +81,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();
@@ -122,6 +123,7 @@ class EntitySearchTest extends TestCase
         $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']);
         $this->asEditor();
         $editorId = $this->getEditor()->id;
+        $editorSlug = $this->getEditor()->slug;
 
         // Viewed filter searches
         $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertSee($page->name);
@@ -133,16 +135,23 @@ class EntitySearchTest extends TestCase
         // User filters
         $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 {updated_by:'.$editorId.'}'))->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);
         $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:'.$editorId.'}'))->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:'.$editorId.'}'))->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);
 
         // Content filters
         $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name);
diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php
deleted file mode 100644 (file)
index de1e025..0000000
+++ /dev/null
@@ -1,365 +0,0 @@
-<?php namespace Tests\Entity;
-
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Auth\UserRepo;
-use BookStack\Entities\Repos\PageRepo;
-use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
-use Tests\BrowserKitTest;
-
-class EntityTest extends BrowserKitTest
-{
-
-    public function test_entity_creation()
-    {
-        // Test Creation
-        $book = $this->bookCreation();
-        $chapter = $this->chapterCreation($book);
-        $page = $this->pageCreation($chapter);
-
-        // Test Updating
-        $book = $this->bookUpdate($book);
-
-        // Test Deletion
-        $this->bookDelete($book);
-    }
-
-    public function bookDelete(Book $book)
-    {
-        $this->asAdmin()
-            ->visit($book->getUrl())
-            // Check link works correctly
-            ->click('Delete')
-            ->seePageIs($book->getUrl() . '/delete')
-            // Ensure the book name is show to user
-            ->see($book->name)
-            ->press('Confirm')
-            ->seePageIs('/books')
-            ->notSeeInDatabase('books', ['id' => $book->id]);
-    }
-
-    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->assertRegExp($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());
-    }
-
-    public function test_page_delete_removes_entity_from_its_activity()
-    {
-        $page = Page::query()->first();
-
-        $this->asEditor()->put($page->getUrl(), [
-            'name' => 'My updated page',
-            'html' => '<p>updated content</p>',
-        ]);
-        $page->refresh();
-
-        $this->seeInDatabase('activities', [
-            'entity_id' => $page->id,
-            'entity_type' => $page->getMorphClass(),
-        ]);
-
-        $resp = $this->delete($page->getUrl());
-        $resp->assertResponseStatus(302);
-
-        $this->dontSeeInDatabase('activities', [
-            'entity_id' => $page->id,
-            'entity_type' => $page->getMorphClass(),
-        ]);
-
-        $this->seeInDatabase('activities', [
-            'extra' => 'My updated page',
-            'entity_id' => 0,
-            'entity_type' => '',
-        ]);
-    }
-
-}
index 5a94adac91c4b8d8dc46866f897e45f7057c3808..aebc5f2455f31a2e2678df44371badabdd1cb89b 100644 (file)
@@ -1,18 +1,20 @@
-<?php namespace Tests\Entity;
+<?php
 
+namespace Tests\Entity;
 
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Uploads\HttpFetcher;
+use BookStack\Auth\Role;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+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 +25,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 +35,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 +46,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 +59,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 +70,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 +97,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 +110,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 +120,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,37 +133,253 @@ 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);
     }
 
+    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);
+    }
+
     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::query()->first();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee($page->getUrl('/revisions'));
+        $resp->assertDontSee($page->createdBy->getProfileUrl());
+        $resp->assertSee($page->createdBy->name);
+    }
+
     public function test_page_export_sets_right_data_type_for_svg_embeds()
     {
-        $page = Page::first();
-        $page->html = '<img src="http://example.com/image.svg">';
+        $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">';
         $page->save();
 
         $this->asEditor();
-        $this->mockHttpFetch('<svg></svg>');
         $resp = $this->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+
         $resp->assertStatus(200);
         $resp->assertSee('<img src="data:image/svg+xml;base64');
     }
 
-}
\ No newline at end of file
+    public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
+    {
+        $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>');
+        $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+        Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
+
+        $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
+    }
+
+    public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
+    {
+        $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"/>';
+        $storageDisk = Storage::disk('local');
+        $storageDisk->makeDirectory('uploads/images/gallery');
+        $storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
+        $storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
+        $page->save();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+
+        $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->assertSee('http://localhost/uploads/svg_test.svg');
+        $resp->assertSee('src="/uploads/svg_test.svg"');
+    }
+
+    public function test_exports_removes_scripts_from_custom_head()
+    {
+        $entities = [
+            Page::query()->first(), Chapter::query()->first(), Book::query()->first(),
+        ];
+        setting()->put('app-custom-head', '<script>window.donkey = "cat";</script><style>.my-test-class { color: red; }</style>');
+
+        foreach ($entities as $entity) {
+            $resp = $this->asEditor()->get($entity->getUrl('/export/html'));
+            $resp->assertDontSee('window.donkey');
+            $resp->assertDontSee('script');
+            $resp->assertSee('.my-test-class { color: red; }');
+        }
+    }
+
+    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_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");
+    }
+
+    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");
+    }
+
+    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 452b4c0..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\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 d714c3229db7a8dfeecd587c67b0d9f41cde4457..45c27c9f9545cb4cec5b7a919a8e56d8df5a1ec4 100644 (file)
@@ -1,16 +1,22 @@
-<?php namespace Tests\Entity;
+<?php
 
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Page;
+namespace Tests\Entity;
+
+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,8 @@ class PageContentTest extends TestCase
 
     public function test_page_includes_do_not_break_tables()
     {
-        $page = Page::first();
-        $secondPage = Page::where('id', '!=', $page->id)->first();
+        $page = Page::query()->first();
+        $secondPage = Page::query()->where('id', '!=', $page->id)->first();
 
         $content = '<table id="table"><tbody><tr><td>test</td></tr></tbody></table>';
         $secondPage->html = $content;
@@ -71,10 +77,29 @@ class PageContentTest extends TestCase
         $pageResp->assertSee($content);
     }
 
+    public function test_page_includes_rendered_on_book_export()
+    {
+        $page = Page::query()->first();
+        $secondPage = Page::query()
+            ->where('book_id', '!=', $page->book_id)
+            ->first();
+
+        $content = '<p id="bkmrk-meow">my cat is awesome and scratchy</p>';
+        $secondPage->html = $content;
+        $secondPage->save();
+
+        $page->html = "{{@{$secondPage->id}#bkmrk-meow}}";
+        $page->save();
+
+        $this->asEditor();
+        $htmlContent = $this->get($page->book->getUrl('/export/html'));
+        $htmlContent->assertSee('my cat is awesome and scratchy');
+    }
+
     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();
@@ -97,7 +122,7 @@ class PageContentTest extends TestCase
         ];
 
         $this->asEditor();
-        $page = Page::first();
+        $page = Page::query()->first();
 
         foreach ($checks as $check) {
             $page->html = $check;
@@ -108,21 +133,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;
@@ -131,19 +167,93 @@ 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>',
+        ];
+
+        $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', '<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>',
+        ];
+
+        $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', '<button id="xss"');
+            $pageView->assertElementNotContains('.page-content', '<input id="xss"');
+            $pageView->assertElementNotContains('.page-content', '<form id="xss"');
+            $pageView->assertElementNotContains('.page-content', 'action=javascript:');
+            $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::query()->first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<meta>');
+            $pageView->assertElementNotContains('.page-content', '</meta>');
+            $pageView->assertElementNotContains('.page-content', 'content=');
+            $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();
@@ -158,15 +268,17 @@ class PageContentTest extends TestCase
     {
         $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;
@@ -176,13 +288,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';
@@ -194,10 +305,32 @@ class PageContentTest extends TestCase
         $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>';
@@ -212,14 +345,14 @@ class PageContentTest extends TestCase
     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());
@@ -229,18 +362,35 @@ 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::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' => '',
+        ]);
+
+        $updatedPage = Page::query()->where('id', '=', $page->id)->first();
+        $this->assertStringContainsString('id="bkmrk-test"', $updatedPage->html);
+        $this->assertStringContainsString('href="#bkmrk-test"', $updatedPage->html);
     }
 
     public function test_get_page_nav_sets_correct_properties()
@@ -252,21 +402,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]);
     }
 
@@ -279,8 +429,8 @@ class PageContentTest extends TestCase
         $this->assertCount(1, $navMap);
         $this->assertArrayMapIncludes([
             'nodeName' => 'h1',
-            'link' => '#testa',
-            'text' => 'Hello'
+            'link'     => '#testa',
+            'text'     => 'Hello',
         ], $navMap[0]);
     }
 
@@ -293,15 +443,168 @@ 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]);
     }
+
+    public function test_page_text_decodes_html_entities()
+    {
+        $page = Page::query()->first();
+
+        $this->actingAs($this->getAdmin())
+            ->put($page->getUrl(''), [
+                'name' => 'Testing',
+                'html' => '<p>&quot;Hello &amp; welcome&quot;</p>',
+            ]);
+
+        $page->refresh();
+        $this->assertEquals('"Hello & welcome"', $page->text);
+    }
+
+    public function test_page_markdown_table_rendering()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $content = '| Syntax      | Description |
+| ----------- | ----------- |
+| Header      | Title       |
+| Paragraph   | Text        |';
+        $this->put($page->getUrl(), [
+            'name' => $page->name,  'markdown' => $content,
+            'html' => '', 'summary' => '',
+        ]);
+
+        $page->refresh();
+        $this->assertStringContainsString('</tbody>', $page->html);
+
+        $pageView = $this->get($page->getUrl());
+        $pageView->assertElementExists('.page-content table tbody td');
+    }
+
+    public function test_page_markdown_task_list_rendering()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $content = '- [ ] Item a
+- [x] Item b';
+        $this->put($page->getUrl(), [
+            'name' => $page->name,  'markdown' => $content,
+            'html' => '', 'summary' => '',
+        ]);
+
+        $page->refresh();
+        $this->assertStringContainsString('input', $page->html);
+        $this->assertStringContainsString('type="checkbox"', $page->html);
+
+        $pageView = $this->get($page->getUrl());
+        $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()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $content = '~~some crossed out text~~';
+        $this->put($page->getUrl(), [
+            'name' => $page->name,  'markdown' => $content,
+            'html' => '', 'summary' => '',
+        ]);
+
+        $page->refresh();
+        $this->assertStringMatchesFormat('%A<s%A>some crossed out text</s>%A', $page->html);
+
+        $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);
+    }
+
+    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_blanked_if_not_supported_extension_for_extract()
+    {
+        $this->asEditor();
+        $page = Page::query()->first();
+
+        $this->put($page->getUrl(), [
+            'name' => $page->name, 'summary' => '',
+            'html' => '<p>test<img src="data:image/jiff;base64,' . $this->base64Jpeg . '"/></p>',
+        ]);
+
+        $page->refresh();
+        $this->assertStringContainsString('<img src=""', $page->html);
+    }
 }
index 5c984940d32569ce3e0b5d1cdce60bef2bf9d1cb..21a74768f319b92100d023d2cb49fc546e02d103 100644 (file)
@@ -1,10 +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;
 
     /**
@@ -15,90 +23,161 @@ class PageDraftTest extends BrowserKitTest
     public function setUp(): void
     {
         parent::setUp();
-        $this->page = \BookStack\Entities\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\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\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\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)->assertJson([
+            'html' => $page->html,
+        ]);
+    }
 }
diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php
new file mode 100644 (file)
index 0000000..9b0a8f1
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PageEditorTest extends TestCase
+{
+    /** @var Page */
+    protected $page;
+
+    public 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,
+        ]);
+    }
+}
index 1e9dbd626b78fd011184e3549cbf239578365b6a..2ed7d3b411e86b8990cc0b000740ec2a79a26bbd 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests\Entity;
+<?php
 
-use BookStack\Entities\Page;
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use Tests\TestCase;
 
@@ -47,8 +49,33 @@ 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');
+        $pageView->assertDontSee('def456');
+
+        $revToRestore = $page->revisions()->where('name', 'like', '%abc123')->first();
+        $restoreReq = $this->put($page->getUrl() . '/revisions/' . $revToRestore->id . '/restore');
+        $page = Page::find($page->id);
 
+        $restoreReq->assertStatus(302);
+        $restoreReq->assertRedirect($page->getUrl());
+
+        $pageView = $this->get($page->getUrl());
+        $pageView->assertSee('abc123');
+        $pageView->assertSee('def456');
+    }
+
+    public function test_page_revision_restore_with_markdown_retains_markdown_content()
+    {
+        $this->asEditor();
+
+        $pageRepo = app(PageRepo::class);
+        $page = Page::first();
+        $pageRepo->update($page, ['name' => 'updated page abc123', 'markdown' => '## New Content def456', 'summary' => 'initial page revision testing']);
+        $pageRepo->update($page, ['name' => 'updated page again', 'markdown' => '## New Content Updated', 'summary' => 'page revision testing']);
+        $page = Page::find($page->id);
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee('abc123');
@@ -56,16 +83,42 @@ 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());
 
         $pageView = $this->get($page->getUrl());
+        $this->assertDatabaseHas('pages', [
+            'id'       => $page->id,
+            'markdown' => '## New Content def456',
+        ]);
         $pageView->assertSee('abc123');
         $pageView->assertSee('def456');
     }
 
+    public function test_page_revision_restore_sets_new_revision_with_summary()
+    {
+        $this->asEditor();
+
+        $pageRepo = app(PageRepo::class);
+        $page = Page::first();
+        $pageRepo->update($page, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'My first update']);
+        $pageRepo->update($page, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => '']);
+        $page->refresh();
+
+        $revToRestore = $page->revisions()->where('name', 'like', '%abc123')->first();
+        $this->put($page->getUrl() . '/revisions/' . $revToRestore->id . '/restore');
+        $page->refresh();
+
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'text'    => 'new contente def456',
+            'type'    => 'version',
+            'summary' => "Restored from #{$revToRestore->id}; My first update",
+        ]);
+    }
+
     public function test_page_revision_count_increments_on_update()
     {
         $page = Page::first();
@@ -73,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()
@@ -89,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']);
 
@@ -149,4 +203,4 @@ class PageRevisionTest extends TestCase
         $revisionCount = $page->revisions()->count();
         $this->assertEquals(12, $revisionCount);
     }
-}
\ No newline at end of file
+}
index 8eba1355792f593be11a7431c6f6866eaa3732cc..3d16895102f5a5f6af570119f2e5234a365e7aaf 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests\Entity;
+<?php
 
-use BookStack\Entities\Page;
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Page;
 use Tests\TestCase;
 
 class PageTemplateTest extends 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
+}
diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php
new file mode 100644 (file)
index 0000000..313fc77
--- /dev/null
@@ -0,0 +1,283 @@
+<?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 = factory(Page::class)->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()
+    {
+        $page = Page::query()->first();
+        $user = $this->getViewer();
+        $owner = $this->getEditor();
+        $page->created_by = $user->id;
+        $page->owned_by = $owner->id;
+        $page->save();
+        $user->delete();
+
+        $resp = $this->asAdmin()->get($page->getUrl());
+        $resp->assertStatus(200);
+        $resp->assertSeeText('Owned by ' . $owner->name);
+    }
+
+    public function test_page_creation_with_markdown_content()
+    {
+        $this->setSettings(['app-editor' => 'markdown']);
+        $book = Book::query()->first();
+
+        $this->asEditor()->get($book->getUrl('/create-page'));
+        $draft = Page::query()->where('book_id', '=', $book->id)
+            ->where('draft', '=', true)->first();
+
+        $details = [
+            'markdown' => '# a title',
+            '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,
+        ]);
+
+        $draft->refresh();
+        $resp = $this->get($draft->getUrl('/edit'));
+        $resp->assertSee('# a title');
+    }
+
+    public function test_page_delete()
+    {
+        $page = Page::query()->first();
+        $this->assertNull($page->deleted_at);
+
+        $deleteViewReq = $this->asEditor()->get($page->getUrl('/delete'));
+        $deleteViewReq->assertSeeText('Are you sure you want to delete this page?');
+
+        $deleteReq = $this->delete($page->getUrl());
+        $deleteReq->assertRedirect($page->getParent()->getUrl());
+        $this->assertActivityExists('page_delete', $page);
+
+        $page->refresh();
+        $this->assertNotNull($page->deleted_at);
+        $this->assertTrue($page->deletions()->count() === 1);
+
+        $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+        $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();
+        $page->html = '<p>This is some test content</p>';
+        $page->save();
+
+        $currentBook = $page->book;
+        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+
+        $resp = $this->asEditor()->get($page->getUrl('/copy'));
+        $resp->assertSee('Copy Page');
+
+        $movePageResp = $this->post($page->getUrl('/copy'), [
+            'entity_selection' => 'book:' . $newBook->id,
+            'name'             => 'My copied test page',
+        ]);
+        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+
+        $movePageResp->assertRedirect($pageCopy->getUrl());
+        $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book');
+        $this->assertStringContainsString('This is some test content', $pageCopy->html);
+    }
+
+    public function test_page_copy_with_markdown_has_both_html_and_markdown()
+    {
+        $page = Page::first();
+        $page->html = '<h1>This is some test content</h1>';
+        $page->markdown = '# This is some test content';
+        $page->save();
+        $newBook = Book::where('id', '!=', $page->book->id)->first();
+
+        $this->asEditor()->post($page->getUrl('/copy'), [
+            'entity_selection' => 'book:' . $newBook->id,
+            'name'             => 'My copied test page',
+        ]);
+        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+
+        $this->assertStringContainsString('This is some test content', $pageCopy->html);
+        $this->assertEquals('# This is some test content', $pageCopy->markdown);
+    }
+
+    public function test_page_copy_with_no_destination()
+    {
+        $page = Page::first();
+        $currentBook = $page->book;
+
+        $resp = $this->asEditor()->get($page->getUrl('/copy'));
+        $resp->assertSee('Copy Page');
+
+        $movePageResp = $this->post($page->getUrl('/copy'), [
+            'name' => 'My copied test page',
+        ]);
+
+        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+
+        $movePageResp->assertRedirect($pageCopy->getUrl());
+        $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book');
+        $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance');
+    }
+
+    public function test_page_can_be_copied_without_edit_permission()
+    {
+        $page = Page::first();
+        $currentBook = $page->book;
+        $newBook = Book::where('id', '!=', $currentBook->id)->first();
+        $viewer = $this->getViewer();
+
+        $resp = $this->actingAs($viewer)->get($page->getUrl());
+        $resp->assertDontSee($page->getUrl('/copy'));
+
+        $newBook->owned_by = $viewer->id;
+        $newBook->save();
+        $this->giveUserPermissions($viewer, ['page-create-own']);
+        $this->regenEntityPermissions($newBook);
+
+        $resp = $this->actingAs($viewer)->get($page->getUrl());
+        $resp->assertSee($page->getUrl('/copy'));
+
+        $movePageResp = $this->post($page->getUrl('/copy'), [
+            'entity_selection' => 'book:' . $newBook->id,
+            'name'             => 'My copied test page',
+        ]);
+        $movePageResp->assertRedirect();
+
+        $this->assertDatabaseHas('pages', [
+            'name'       => 'My copied test page',
+            'created_by' => $viewer->id,
+            'book_id'    => $newBook->id,
+        ]);
+    }
+
+    public function test_old_page_slugs_redirect_to_new_pages()
+    {
+        /** @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_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);
+    }
+}
index 727db553367fe647462a9c880a4c9bbb16998dc8..25a0ae7209572b18145e1cf9411c8fff4b2edd93 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests\Entity;
+<?php
 
-use BookStack\Entities\SearchOptions;
+namespace Tests\Entity;
+
+use BookStack\Entities\Tools\SearchOptions;
 use Tests\TestCase;
 
 class SearchOptionsTest extends 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 28c3adf312682fa84783a78634eb946681c98e70..5cfc5c3c5b935f9f2396fc38b4bf0521bfed1b6f 100644 (file)
@@ -1,8 +1,10 @@
-<?php namespace Tests\Entity;
+<?php
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use Tests\TestCase;
 
@@ -39,7 +41,7 @@ class SortTest extends TestCase
         $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);
 
@@ -59,7 +61,7 @@ class SortTest extends TestCase
         $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);
 
@@ -77,9 +79,9 @@ class SortTest extends TestCase
         $newBook = Book::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 = Page::find($page->id);
+        $page->refresh();
 
         $movePageResp->assertRedirect($page->getUrl());
         $this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book');
@@ -91,21 +93,21 @@ class SortTest extends TestCase
 
     public function test_page_move_requires_create_permissions_on_parent()
     {
-        $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', 'delete'], $editor->roles);
+        $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);
+        $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);
@@ -121,19 +123,19 @@ class SortTest extends TestCase
         $newBook = Book::where('id', '!=', $currentBook->id)->first();
         $editor = $this->getEditor();
 
-        $this->setEntityRestrictions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles);
-        $this->setEntityRestrictions($page, ['view', 'update', 'create'], $editor->roles);
+        $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());
         $pageView->assertDontSee($page->getUrl('/move'));
 
-        $this->setEntityRestrictions($page, ['view', 'update', 'create', 'delete'], $editor->roles);
+        $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);
@@ -152,7 +154,7 @@ class SortTest extends TestCase
         $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);
@@ -176,19 +178,19 @@ class SortTest extends TestCase
         $newBook = Book::where('id', '!=', $currentBook->id)->first();
         $editor = $this->getEditor();
 
-        $this->setEntityRestrictions($newBook, ['view', 'update', 'create', 'delete'], $editor->roles);
-        $this->setEntityRestrictions($chapter, ['view', 'update', 'create'], $editor->roles);
+        $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());
         $pageView->assertDontSee($chapter->getUrl('/move'));
 
-        $this->setEntityRestrictions($chapter, ['view', 'update', 'create', 'delete'], $editor->roles);
+        $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);
@@ -196,6 +198,37 @@ class SortTest extends TestCase
         $this->assertTrue($chapter->book->id == $newBook->id, 'Page book is now the new book');
     }
 
+    public function test_chapter_move_changes_book_for_deleted_pages_within()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->whereHas('pages')->first();
+        $currentBook = $chapter->book;
+        $pageToCheck = $chapter->pages->first();
+        $newBook = Book::query()->where('id', '!=', $currentBook->id)->first();
+
+        $pageToCheck->delete();
+
+        $this->asEditor()->put($chapter->getUrl('/move'), [
+            '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();
@@ -206,20 +239,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,
             ];
         }
 
@@ -227,9 +260,9 @@ 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);
@@ -239,73 +272,39 @@ class SortTest extends TestCase
         $checkResp->assertSee($newBook->name);
     }
 
-    public function test_page_copy()
+    public function test_book_sort_item_returns_book_content()
     {
-        $page = Page::first();
-        $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
-
-        $resp = $this->asEditor()->get($page->getUrl('/copy'));
-        $resp->assertSee('Copy Page');
-
-        $movePageResp = $this->post($page->getUrl('/copy'), [
-            'entity_selection' => 'book:' . $newBook->id,
-            'name' => 'My copied test page'
-        ]);
-        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
-
-        $movePageResp->assertRedirect($pageCopy->getUrl());
-        $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book');
-    }
-
-    public function test_page_copy_with_no_destination()
-    {
-        $page = Page::first();
-        $currentBook = $page->book;
-
-        $resp = $this->asEditor()->get($page->getUrl('/copy'));
-        $resp->assertSee('Copy Page');
+        $books = Book::all();
+        $bookToSort = $books[0];
+        $firstPage = $bookToSort->pages[0];
+        $firstChapter = $bookToSort->chapters[0];
 
-        $movePageResp = $this->post($page->getUrl('/copy'), [
-            'name' => 'My copied test page'
-        ]);
-
-        $pageCopy = Page::where('name', '=', 'My copied test page')->first();
+        $resp = $this->asAdmin()->get($bookToSort->getUrl() . '/sort-item');
 
-        $movePageResp->assertRedirect($pageCopy->getUrl());
-        $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book');
-        $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance');
+        // Ensure book details are returned
+        $resp->assertSee($bookToSort->name);
+        $resp->assertSee($firstPage->name);
+        $resp->assertSee($firstChapter->name);
     }
 
-    public function test_page_can_be_copied_without_edit_permission()
+    public function test_pages_in_book_show_sorted_by_priority()
     {
-        $page = Page::first();
-        $currentBook = $page->book;
-        $newBook = Book::where('id', '!=', $currentBook->id)->first();
-        $viewer = $this->getViewer();
-
-        $resp = $this->actingAs($viewer)->get($page->getUrl());
-        $resp->assertDontSee($page->getUrl('/copy'));
-
-        $newBook->created_by = $viewer->id;
-        $newBook->save();
-        $this->giveUserPermissions($viewer, ['page-create-own']);
-        $this->regenEntityPermissions($newBook);
-
-        $resp = $this->actingAs($viewer)->get($page->getUrl());
-        $resp->assertSee($page->getUrl('/copy'));
-
-        $movePageResp = $this->post($page->getUrl('/copy'), [
-            'entity_selection' => 'book:' . $newBook->id,
-            'name' => 'My copied test page'
-        ]);
-        $movePageResp->assertRedirect();
-
-        $this->assertDatabaseHas('pages', [
-            'name' => 'My copied test page',
-            'created_by' => $viewer->id,
-            'book_id' => $newBook->id,
-        ]);
+        /** @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);
     }
-
-}
\ No newline at end of file
+}
index e8a99cf781b6bd972ee43708025da51d081c1c51..74da37f4a867cbea8f6ced182a33f06ad4f12a0b 100644 (file)
@@ -1,32 +1,29 @@
-<?php namespace Tests\Entity;
+<?php
+
+namespace Tests\Entity;
 
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
 use BookStack\Actions\Tag;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
-use BookStack\Auth\Permissions\PermissionService;
-use Tests\BrowserKitTest;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+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) {
+        if (is_null($tags)) {
             $tags = factory(Tag::class, $this->defaultTagCount)->make();
         }
 
         $entity->tags()->saveMany($tags);
+
         return $entity;
     }
 
@@ -40,12 +37,12 @@ class TagTest extends BrowserKitTest
         $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);
+        $page = $this->getEntityWithTags(Page::class, $attrs->all());
 
-        $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']);
+        $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->assertExactJson([]);
+        $this->get('/ajax/tags/suggest/names?search=co')->assertExactJson(['color', 'country', 'county']);
+        $this->get('/ajax/tags/suggest/names?search=cou')->assertExactJson(['country', 'county']);
+        $this->get('/ajax/tags/suggest/names?search=pla')->assertExactJson(['planet', 'plans']);
     }
 
     public function test_tag_value_suggestions()
@@ -58,34 +55,47 @@ class TagTest extends BrowserKitTest
         $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);
+        $page = $this->getEntityWithTags(Page::class, $attrs->all());
 
-        $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']);
+        $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->assertExactJson([]);
+        $this->get('/ajax/tags/suggest/values?search=cat')->assertExactJson(['cats', 'cattery', 'catapult']);
+        $this->get('/ajax/tags/suggest/values?search=do')->assertExactJson(['dog', 'dodgy']);
+        $this->get('/ajax/tags/suggest/values?search=cas')->assertExactJson(['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);
+        $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')->assertExactJson(['color', 'country']);
+        $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertExactJson(['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')->assertExactJson(['color', 'country']);
+        $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->assertExactJson([]);
     }
 
+    public function test_tags_shown_on_search_listing()
+    {
+        $tags = [
+            factory(Tag::class)->make(['name' => 'category', 'value' => 'buckets']),
+            factory(Tag::class)->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');
+    }
 }
index 8f6867cdeb0c357e16f9b7ce7df7df3813984200..2eeb6537e0e0b9afaf5823ad99a66e2eab7a1322 100644 (file)
@@ -1,11 +1,12 @@
-<?php namespace Tests;
+<?php
 
-use BookStack\Entities\Book;
+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);
+    }
+}
index ada1f5aafde22b4929d31166be3d156a7c98e053..e27b787745ff3db74ed5db183fb2fe7b9afbd30b 100644 (file)
@@ -1,10 +1,14 @@
-<?php namespace Tests;
+<?php
 
-use BookStack\Entities\Bookshelf;
+namespace Tests;
+
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Page;
 
 class HomepageTest extends TestCase
 {
-
     public function test_default_homepage_visible()
     {
         $this->asEditor();
@@ -39,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('/');
@@ -65,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());
@@ -75,6 +79,25 @@ class HomepageTest extends TestCase
         $pageDeleteReq->assertSessionMissing('error');
     }
 
+    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();
@@ -98,16 +121,16 @@ class HomepageTest extends TestCase
     {
         $editor = $this->getEditor();
         setting()->putUser($editor, 'bookshelves_view_type', 'grid');
+        $shelf = Bookshelf::query()->firstOrFail();
 
         $this->setSettings(['app-homepage-type' => 'bookshelves']);
 
         $this->asEditor();
         $homeVisit = $this->get('/');
         $homeVisit->assertSee('Shelves');
-        $homeVisit->assertSee('bookshelf-grid-item grid-card');
         $homeVisit->assertSee('grid-card-content');
-        $homeVisit->assertSee('grid-card-footer');
         $homeVisit->assertSee('featured-image-container');
+        $homeVisit->assertElementContains('.grid-card', $shelf->name);
 
         $this->setSettings(['app-homepage-type' => false]);
         $this->test_default_homepage_visible();
@@ -141,4 +164,14 @@ class HomepageTest extends TestCase
         $homeVisit->assertElementContains('.content-wrap', $shelf->name);
         $homeVisit->assertElementContains('.content-wrap', $book->name);
     }
+
+    public function test_new_users_dont_have_any_recently_viewed()
+    {
+        $user = factory(User::class)->create();
+        $viewRole = Role::getRole('Viewer');
+        $user->attachRole($viewRole);
+
+        $homeVisit = $this->actingAs($user)->get('/');
+        $homeVisit->assertElementContains('#recently-viewed', 'You have not viewed any pages');
+    }
 }
index d5c6e453238ead0b3d82f0d805d23f7983fdf676..a9070248e5f86ab4fc333d0110e72d6a42ecf2a4 100644 (file)
@@ -1,8 +1,9 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
 class LanguageTest extends TestCase
 {
-
     protected $langs;
 
     /**
@@ -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;
+    }
+}
diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php
new file mode 100644 (file)
index 0000000..fe50866
--- /dev/null
@@ -0,0 +1,49 @@
+<?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 Tests\TestCase;
+
+class EntityOwnerChangeTest extends TestCase
+{
+    public function test_changing_page_owner()
+    {
+        $page = Page::query()->first();
+        $user = User::query()->where('id', '!=', $page->owned_by)->first();
+
+        $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]);
+        $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]);
+    }
+
+    public function test_changing_chapter_owner()
+    {
+        $chapter = Chapter::query()->first();
+        $user = User::query()->where('id', '!=', $chapter->owned_by)->first();
+
+        $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]);
+        $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]);
+    }
+
+    public function test_changing_book_owner()
+    {
+        $book = Book::query()->first();
+        $user = User::query()->where('id', '!=', $book->owned_by)->first();
+
+        $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]);
+        $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]);
+    }
+
+    public function test_changing_shelf_owner()
+    {
+        $shelf = Bookshelf::query()->first();
+        $user = User::query()->where('id', '!=', $shelf->owned_by)->first();
+
+        $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]);
+        $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]);
+    }
+}
diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php
new file mode 100644 (file)
index 0000000..bb011cf
--- /dev/null
@@ -0,0 +1,740 @@
+<?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\Entities\Models\Page;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+
+class EntityPermissionsTest extends TestCase
+{
+    /**
+     * @var User
+     */
+    protected $user;
+
+    /**
+     * @var User
+     */
+    protected $viewer;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->user = $this->getEditor();
+        $this->viewer = $this->getViewer();
+    }
+
+    protected function setRestrictionsForTestRoles(Entity $entity, array $actions = [])
+    {
+        $roles = [
+            $this->user->roles->first(),
+            $this->viewer->roles->first(),
+        ];
+        $this->setEntityRestrictions($entity, $actions, $roles);
+    }
+
+    public function test_bookshelf_view_restriction()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+
+        $this->actingAs($this->user)
+            ->get($shelf->getUrl())
+            ->assertStatus(200);
+
+        $this->setRestrictionsForTestRoles($shelf, []);
+
+        $this->followingRedirects()->get($shelf->getUrl())
+            ->assertSee('Bookshelf not found');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view']);
+
+        $this->get($shelf->getUrl())
+            ->assertSee($shelf->name);
+    }
+
+    public function test_bookshelf_update_restriction()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+
+        $this->actingAs($this->user)
+            ->get($shelf->getUrl('/edit'))
+            ->assertSee('Edit Book');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
+
+        $resp = $this->get($shelf->getUrl('/edit'))
+            ->assertRedirect('/');
+        $this->followRedirects($resp)->assertSee('You do not have permission');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
+
+        $this->get($shelf->getUrl('/edit'))
+            ->assertOk();
+    }
+
+    public function test_bookshelf_delete_restriction()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+
+        $this->actingAs($this->user)
+            ->get($shelf->getUrl('/delete'))
+            ->assertSee('Delete Book');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
+
+        $this->get($shelf->getUrl('/delete'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
+
+        $this->get($shelf->getUrl('/delete'))
+            ->assertOk()
+            ->assertSee('Delete Book');
+    }
+
+    public function test_book_view_restriction()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookPage = $book->pages->first();
+        $bookChapter = $book->chapters->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->user)
+            ->get($bookUrl)
+            ->assertOk();
+
+        $this->setRestrictionsForTestRoles($book, []);
+
+        $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->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()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->viewer)
+            ->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
+        $this->actingAs($this->user)
+            ->get($bookUrl)
+            ->assertElementContains('.actions', 'New Page')
+            ->assertElementContains('.actions', 'New Chapter');
+
+        $this->setRestrictionsForTestRoles($book, ['view', 'delete', 'update']);
+
+        $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']);
+
+        $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()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookPage = $book->pages->first();
+        $bookChapter = $book->chapters->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->user)
+            ->get($bookUrl . '/edit')
+            ->assertSee('Edit Book');
+
+        $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
+
+        $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->get($bookUrl . '/edit')->assertOk();
+        $this->get($bookPage->getUrl() . '/edit')->assertOk();
+        $this->get($bookChapter->getUrl() . '/edit')->assertSee('Edit Chapter');
+    }
+
+    public function test_book_delete_restriction()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookPage = $book->pages->first();
+        $bookChapter = $book->chapters->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->user)->get($bookUrl . '/delete')
+            ->assertSee('Delete Book');
+
+        $this->setRestrictionsForTestRoles($book, ['view', 'update']);
+
+        $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->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()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $chapterPage = $chapter->pages->first();
+
+        $chapterUrl = $chapter->getUrl();
+        $this->actingAs($this->user)->get($chapterUrl)->assertOk();
+
+        $this->setRestrictionsForTestRoles($chapter, []);
+
+        $this->followingRedirects()->get($chapterUrl)->assertSee('Chapter not found');
+        $this->followingRedirects()->get($chapterPage->getUrl())->assertSee('Page not found');
+
+        $this->setRestrictionsForTestRoles($chapter, ['view']);
+
+        $this->get($chapterUrl)->assertSee($chapter->name);
+        $this->get($chapterPage->getUrl())->assertSee($chapterPage->name);
+    }
+
+    public function test_chapter_create_restriction()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+
+        $chapterUrl = $chapter->getUrl();
+        $this->actingAs($this->user)
+            ->get($chapterUrl)
+            ->assertElementContains('.actions', 'New Page');
+
+        $this->setRestrictionsForTestRoles($chapter, ['view', 'delete', 'update']);
+
+        $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->get($chapterUrl)->assertElementContains('.actions', 'New Page');
+    }
+
+    public function test_chapter_update_restriction()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $chapterPage = $chapter->pages->first();
+
+        $chapterUrl = $chapter->getUrl();
+        $this->actingAs($this->user)->get($chapterUrl . '/edit')
+            ->assertSee('Edit Chapter');
+
+        $this->setRestrictionsForTestRoles($chapter, ['view', 'delete']);
+
+        $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->get($chapterUrl . '/edit')->assertOk()->assertSee('Edit Chapter');
+        $this->get($chapterPage->getUrl() . '/edit')->assertOk();
+    }
+
+    public function test_chapter_delete_restriction()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $chapterPage = $chapter->pages->first();
+
+        $chapterUrl = $chapter->getUrl();
+        $this->actingAs($this->user)
+            ->get($chapterUrl . '/delete')
+            ->assertSee('Delete Chapter');
+
+        $this->setRestrictionsForTestRoles($chapter, ['view', 'update']);
+
+        $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->get($chapterUrl . '/delete')->assertOk()->assertSee('Delete Chapter');
+        $this->get($chapterPage->getUrl() . '/delete')->assertOk()->assertSee('Delete Page');
+    }
+
+    public function test_page_view_restriction()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $pageUrl = $page->getUrl();
+        $this->actingAs($this->user)->get($pageUrl)->assertOk();
+
+        $this->setRestrictionsForTestRoles($page, ['update', 'delete']);
+
+        $this->get($pageUrl)->assertSee('Page not found');
+
+        $this->setRestrictionsForTestRoles($page, ['view']);
+
+        $this->get($pageUrl)->assertSee($page->name);
+    }
+
+    public function test_page_update_restriction()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $pageUrl = $page->getUrl();
+        $this->actingAs($this->user)
+            ->get($pageUrl . '/edit')
+            ->assertElementExists('input[name="name"][value="' . $page->name . '"]');
+
+        $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
+
+        $this->get($pageUrl . '/edit')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->setRestrictionsForTestRoles($page, ['view', 'update']);
+
+        $this->get($pageUrl . '/edit')
+            ->assertOk()
+            ->assertElementExists('input[name="name"][value="' . $page->name . '"]');
+    }
+
+    public function test_page_delete_restriction()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $pageUrl = $page->getUrl();
+        $this->actingAs($this->user)
+            ->get($pageUrl . '/delete')
+            ->assertSee('Delete Page');
+
+        $this->setRestrictionsForTestRoles($page, ['view', 'update']);
+
+        $this->get($pageUrl . '/delete')->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->setRestrictionsForTestRoles($page, ['view', 'delete']);
+
+        $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()
+    {
+        $this->entityRestrictionFormTest(Bookshelf::class, 'Bookshelf Permissions', 'view', '2');
+    }
+
+    public function test_book_restriction_form()
+    {
+        $this->entityRestrictionFormTest(Book::class, 'Book Permissions', 'view', '2');
+    }
+
+    public function test_chapter_restriction_form()
+    {
+        $this->entityRestrictionFormTest(Chapter::class, 'Chapter Permissions', 'update', '2');
+    }
+
+    public function test_page_restriction_form()
+    {
+        $this->entityRestrictionFormTest(Page::class, 'Page Permissions', 'delete', '2');
+    }
+
+    public function test_restricted_pages_not_visible_in_book_navigation_on_pages()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages->first();
+        $page2 = $chapter->pages[2];
+
+        $this->setRestrictionsForTestRoles($page, []);
+
+        $this->actingAs($this->user)
+            ->get($page2->getUrl())
+            ->assertElementNotContains('.sidebar-page-list', $page->name);
+    }
+
+    public function test_restricted_pages_not_visible_in_book_navigation_on_chapters()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages->first();
+
+        $this->setRestrictionsForTestRoles($page, []);
+
+        $this->actingAs($this->user)
+            ->get($chapter->getUrl())
+            ->assertElementNotContains('.sidebar-page-list', $page->name);
+    }
+
+    public function test_restricted_pages_not_visible_on_chapter_pages()
+    {
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages->first();
+
+        $this->setRestrictionsForTestRoles($page, []);
+
+        $this->actingAs($this->user)
+            ->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)
+            ->get($chapter->book->getUrl())
+            ->assertSee($chapter->pages->first()->name);
+
+        foreach ($chapter->pages as $page) {
+            $this->setRestrictionsForTestRoles($page, []);
+        }
+
+        $this->actingAs($this->user)
+            ->get($chapter->book->getUrl())
+            ->assertDontSee($chapter->pages->first()->name);
+    }
+
+    public function test_bookshelf_update_restriction_override()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+
+        $this->actingAs($this->viewer)
+            ->get($shelf->getUrl('/edit'))
+            ->assertDontSee('Edit Book');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
+
+        $this->get($shelf->getUrl('/edit'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
+
+        $this->get($shelf->getUrl('/edit'))->assertOk();
+    }
+
+    public function test_bookshelf_delete_restriction_override()
+    {
+        /** @var Bookshelf $shelf */
+        $shelf = Bookshelf::query()->first();
+
+        $this->actingAs($this->viewer)
+            ->get($shelf->getUrl('/delete'))
+            ->assertDontSee('Delete Book');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'update']);
+
+        $this->get($shelf->getUrl('/delete'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $this->setRestrictionsForTestRoles($shelf, ['view', 'delete']);
+
+        $this->get($shelf->getUrl('/delete'))->assertOk()->assertSee('Delete Book');
+    }
+
+    public function test_book_create_restriction_override()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->viewer)
+            ->get($bookUrl)
+            ->assertElementNotContains('.actions', 'New Page')
+            ->assertElementNotContains('.actions', 'New Chapter');
+
+        $this->setRestrictionsForTestRoles($book, ['view', 'delete', 'update']);
+
+        $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']);
+
+        $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()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookPage = $book->pages->first();
+        $bookChapter = $book->chapters->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->viewer)->get($bookUrl . '/edit')
+            ->assertDontSee('Edit Book');
+
+        $this->setRestrictionsForTestRoles($book, ['view', 'delete']);
+
+        $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->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()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookPage = $book->pages->first();
+        $bookChapter = $book->chapters->first();
+
+        $bookUrl = $book->getUrl();
+        $this->actingAs($this->viewer)
+            ->get($bookUrl . '/delete')
+            ->assertDontSee('Delete Book');
+
+        $this->setRestrictionsForTestRoles($book, ['view', 'update']);
+
+        $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->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()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $bookChapter = $book->chapters->first();
+        $bookPage = $bookChapter->pages->first();
+
+        foreach ([$book, $bookChapter, $bookPage] as $entity) {
+            $entity->name = Str::random(24);
+            $entity->save();
+        }
+
+        $this->setRestrictionsForTestRoles($book, []);
+        $this->setRestrictionsForTestRoles($bookPage, ['view']);
+
+        $this->actingAs($this->viewer);
+        $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()
+    {
+        /** @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)->get($secondBook->getUrl('/sort'))->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        // Check sort page on first book
+        $this->actingAs($this->user)->get($firstBook->getUrl('/sort'));
+    }
+
+    public function test_book_sort_permission()
+    {
+        /** @var Book $firstBook */
+        $firstBook = Book::query()->first();
+        /** @var Book $secondBook */
+        $secondBook = Book::query()->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)])
+            ->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+
+        $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)])
+                ->assertRedirect('/');
+        $this->get('/')->assertSee('You do not have permission');
+    }
+
+    public function test_can_create_page_if_chapter_has_permissions_when_book_not_visible()
+    {
+        /** @var Book $book */
+        $book = Book::query()->first();
+        $this->setRestrictionsForTestRoles($book, []);
+        $bookChapter = $book->chapters->first();
+        $this->setRestrictionsForTestRoles($bookChapter, ['view']);
+
+        $this->actingAs($this->user)->get($bookChapter->getUrl())
+            ->assertDontSee('New Page');
+
+        $this->setRestrictionsForTestRoles($bookChapter, ['view', 'create']);
+
+        $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'));
+    }
+}
diff --git a/tests/Permissions/ExportPermissionsTest.php b/tests/Permissions/ExportPermissionsTest.php
new file mode 100644 (file)
index 0000000..2e3d84f
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+namespace Tests\Permissions;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+
+class ExportPermissionsTest extends TestCase
+{
+    public function test_page_content_without_view_access_hidden_on_chapter_export()
+    {
+        $chapter = Chapter::query()->first();
+        $page = $chapter->pages()->firstOrFail();
+        $pageContent = Str::random(48);
+        $page->html = '<p>' . $pageContent . '</p>';
+        $page->save();
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer);
+        $formats = ['html', 'plaintext'];
+
+        foreach ($formats as $format) {
+            $resp = $this->get($chapter->getUrl("export/{$format}"));
+            $resp->assertStatus(200);
+            $resp->assertSee($page->name);
+            $resp->assertSee($pageContent);
+        }
+
+        $this->setEntityRestrictions($page, []);
+
+        foreach ($formats as $format) {
+            $resp = $this->get($chapter->getUrl("export/{$format}"));
+            $resp->assertStatus(200);
+            $resp->assertDontSee($page->name);
+            $resp->assertDontSee($pageContent);
+        }
+    }
+
+    public function test_page_content_without_view_access_hidden_on_book_export()
+    {
+        $book = Book::query()->first();
+        $page = $book->pages()->firstOrFail();
+        $pageContent = Str::random(48);
+        $page->html = '<p>' . $pageContent . '</p>';
+        $page->save();
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer);
+        $formats = ['html', 'plaintext'];
+
+        foreach ($formats as $format) {
+            $resp = $this->get($book->getUrl("export/{$format}"));
+            $resp->assertStatus(200);
+            $resp->assertSee($page->name);
+            $resp->assertSee($pageContent);
+        }
+
+        $this->setEntityRestrictions($page, []);
+
+        foreach ($formats as $format) {
+            $resp = $this->get($book->getUrl("export/{$format}"));
+            $resp->assertStatus(200);
+            $resp->assertDontSee($page->name);
+            $resp->assertDontSee($pageContent);
+        }
+    }
+}
diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php
deleted file mode 100644 (file)
index 7d6c183..0000000
+++ /dev/null
@@ -1,726 +0,0 @@
-<?php namespace Tests\Permissions;
-
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Auth\User;
-use BookStack\Entities\Page;
-use Tests\BrowserKitTest;
-
-class RestrictionsTest extends BrowserKitTest
-{
-
-    /**
-     * @var User
-     */
-    protected $user;
-
-    /**
-     * @var User
-     */
-    protected $viewer;
-
-    public function setUp(): void
-    {
-        parent::setUp();
-        $this->user = $this->getEditor();
-        $this->viewer = $this->getViewer();
-    }
-
-    protected function setEntityRestrictions(Entity $entity, $actions = [], $roles = [])
-    {
-        $roles = [
-            $this->user->roles->first(),
-            $this->viewer->roles->first(),
-        ];
-        parent::setEntityRestrictions($entity, $actions, $roles);
-    }
-
-    public function test_bookshelf_view_restriction()
-    {
-        $shelf = Bookshelf::first();
-
-        $this->actingAs($this->user)
-            ->visit($shelf->getUrl())
-            ->seePageIs($shelf->getUrl());
-
-        $this->setEntityRestrictions($shelf, []);
-
-        $this->forceVisit($shelf->getUrl())
-            ->see('Bookshelf not found');
-
-        $this->setEntityRestrictions($shelf, ['view']);
-
-        $this->visit($shelf->getUrl())
-            ->see($shelf->name);
-    }
-
-    public function test_bookshelf_update_restriction()
-    {
-        $shelf = BookShelf::first();
-
-        $this->actingAs($this->user)
-            ->visit($shelf->getUrl('/edit'))
-            ->see('Edit Book');
-
-        $this->setEntityRestrictions($shelf, ['view', 'delete']);
-
-        $this->forceVisit($shelf->getUrl('/edit'))
-            ->see('You do not have permission')->seePageIs('/');
-
-        $this->setEntityRestrictions($shelf, ['view', 'update']);
-
-        $this->visit($shelf->getUrl('/edit'))
-            ->seePageIs($shelf->getUrl('/edit'));
-    }
-
-    public function test_bookshelf_delete_restriction()
-    {
-        $shelf = Book::first();
-
-        $this->actingAs($this->user)
-            ->visit($shelf->getUrl('/delete'))
-            ->see('Delete Book');
-
-        $this->setEntityRestrictions($shelf, ['view', 'update']);
-
-        $this->forceVisit($shelf->getUrl('/delete'))
-            ->see('You do not have permission')->seePageIs('/');
-
-        $this->setEntityRestrictions($shelf, ['view', 'delete']);
-
-        $this->visit($shelf->getUrl('/delete'))
-            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
-    }
-
-    public function test_book_view_restriction()
-    {
-        $book = Book::first();
-        $bookPage = $book->pages->first();
-        $bookChapter = $book->chapters->first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->user)
-            ->visit($bookUrl)
-            ->seePageIs($bookUrl);
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($book, ['view']);
-
-        $this->visit($bookUrl)
-            ->see($book->name);
-        $this->visit($bookPage->getUrl())
-            ->see($bookPage->name);
-        $this->visit($bookChapter->getUrl())
-            ->see($bookChapter->name);
-    }
-
-    public function test_book_create_restriction()
-    {
-        $book = Book::first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->viewer)
-            ->visit($bookUrl)
-            ->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
-        $this->actingAs($this->user)
-            ->visit($bookUrl)
-            ->seeInElement('.actions', 'New Page')
-            ->seeInElement('.actions', 'New Chapter');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_book_update_restriction()
-    {
-        $book = Book::first();
-        $bookPage = $book->pages->first();
-        $bookChapter = $book->chapters->first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->user)
-            ->visit($bookUrl . '/edit')
-            ->see('Edit Book');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_book_delete_restriction()
-    {
-        $book = Book::first();
-        $bookPage = $book->pages->first();
-        $bookChapter = $book->chapters->first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->user)
-            ->visit($bookUrl . '/delete')
-            ->see('Delete Book');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_chapter_view_restriction()
-    {
-        $chapter = Chapter::first();
-        $chapterPage = $chapter->pages->first();
-
-        $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl)
-            ->seePageIs($chapterUrl);
-
-        $this->setEntityRestrictions($chapter, []);
-
-        $this->forceVisit($chapterUrl)
-            ->see('Chapter not found');
-        $this->forceVisit($chapterPage->getUrl())
-            ->see('Page not found');
-
-        $this->setEntityRestrictions($chapter, ['view']);
-
-        $this->visit($chapterUrl)
-            ->see($chapter->name);
-        $this->visit($chapterPage->getUrl())
-            ->see($chapterPage->name);
-    }
-
-    public function test_chapter_create_restriction()
-    {
-        $chapter = Chapter::first();
-
-        $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl)
-            ->seeInElement('.actions', 'New Page');
-
-        $this->setEntityRestrictions($chapter, ['view', 'delete', 'update']);
-
-        $this->forceVisit($chapterUrl . '/create-page')
-            ->see('You do not have permission')->seePageIs('/');
-        $this->visit($chapterUrl)->dontSeeInElement('.actions', 'New Page');
-
-        $this->setEntityRestrictions($chapter, ['view', 'create']);
-
-
-        $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');
-    }
-
-    public function test_chapter_update_restriction()
-    {
-        $chapter = Chapter::first();
-        $chapterPage = $chapter->pages->first();
-
-        $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl . '/edit')
-            ->see('Edit Chapter');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($chapter, ['view', 'update']);
-
-        $this->visit($chapterUrl . '/edit')
-            ->seePageIs($chapterUrl . '/edit')->see('Edit Chapter');
-        $this->visit($chapterPage->getUrl() . '/edit')
-            ->seePageIs($chapterPage->getUrl() . '/edit');
-    }
-
-    public function test_chapter_delete_restriction()
-    {
-        $chapter = Chapter::first();
-        $chapterPage = $chapter->pages->first();
-
-        $chapterUrl = $chapter->getUrl();
-        $this->actingAs($this->user)
-            ->visit($chapterUrl . '/delete')
-            ->see('Delete Chapter');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_page_view_restriction()
-    {
-        $page = Page::first();
-
-        $pageUrl = $page->getUrl();
-        $this->actingAs($this->user)
-            ->visit($pageUrl)
-            ->seePageIs($pageUrl);
-
-        $this->setEntityRestrictions($page, ['update', 'delete']);
-
-        $this->forceVisit($pageUrl)
-            ->see('Page not found');
-
-        $this->setEntityRestrictions($page, ['view']);
-
-        $this->visit($pageUrl)
-            ->see($page->name);
-    }
-
-    public function test_page_update_restriction()
-    {
-        $page = Chapter::first();
-
-        $pageUrl = $page->getUrl();
-        $this->actingAs($this->user)
-            ->visit($pageUrl . '/edit')
-            ->seeInField('name', $page->name);
-
-        $this->setEntityRestrictions($page, ['view', 'delete']);
-
-        $this->forceVisit($pageUrl . '/edit')
-            ->see('You do not have permission')->seePageIs('/');
-
-        $this->setEntityRestrictions($page, ['view', 'update']);
-
-        $this->visit($pageUrl . '/edit')
-            ->seePageIs($pageUrl . '/edit')->seeInField('name', $page->name);
-    }
-
-    public function test_page_delete_restriction()
-    {
-        $page = Page::first();
-
-        $pageUrl = $page->getUrl();
-        $this->actingAs($this->user)
-            ->visit($pageUrl . '/delete')
-            ->see('Delete Page');
-
-        $this->setEntityRestrictions($page, ['view', 'update']);
-
-        $this->forceVisit($pageUrl . '/delete')
-            ->see('You do not have permission')->seePageIs('/');
-
-        $this->setEntityRestrictions($page, ['view', 'delete']);
-
-        $this->visit($pageUrl . '/delete')
-            ->seePageIs($pageUrl . '/delete')->see('Delete Page');
-    }
-
-    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'
-            ]);
-    }
-
-    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'
-            ]);
-    }
-
-    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'
-            ]);
-    }
-
-    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'
-            ]);
-    }
-
-    public function test_restricted_pages_not_visible_in_book_navigation_on_pages()
-    {
-        $chapter = Chapter::first();
-        $page = $chapter->pages->first();
-        $page2 = $chapter->pages[2];
-
-        $this->setEntityRestrictions($page, []);
-
-        $this->actingAs($this->user)
-            ->visit($page2->getUrl())
-            ->dontSeeInElement('.sidebar-page-list', $page->name);
-    }
-
-    public function test_restricted_pages_not_visible_in_book_navigation_on_chapters()
-    {
-        $chapter = Chapter::first();
-        $page = $chapter->pages->first();
-
-        $this->setEntityRestrictions($page, []);
-
-        $this->actingAs($this->user)
-            ->visit($chapter->getUrl())
-            ->dontSeeInElement('.sidebar-page-list', $page->name);
-    }
-
-    public function test_restricted_pages_not_visible_on_chapter_pages()
-    {
-        $chapter = Chapter::first();
-        $page = $chapter->pages->first();
-
-        $this->setEntityRestrictions($page, []);
-
-        $this->actingAs($this->user)
-            ->visit($chapter->getUrl())
-            ->dontSee($page->name);
-    }
-
-    public function test_bookshelf_update_restriction_override()
-    {
-        $shelf = Bookshelf::first();
-
-        $this->actingAs($this->viewer)
-            ->visit($shelf->getUrl('/edit'))
-            ->dontSee('Edit Book');
-
-        $this->setEntityRestrictions($shelf, ['view', 'delete']);
-
-        $this->forceVisit($shelf->getUrl('/edit'))
-            ->see('You do not have permission')->seePageIs('/');
-
-        $this->setEntityRestrictions($shelf, ['view', 'update']);
-
-        $this->visit($shelf->getUrl('/edit'))
-            ->seePageIs($shelf->getUrl('/edit'));
-    }
-
-    public function test_bookshelf_delete_restriction_override()
-    {
-        $shelf = Bookshelf::first();
-
-        $this->actingAs($this->viewer)
-            ->visit($shelf->getUrl('/delete'))
-            ->dontSee('Delete Book');
-
-        $this->setEntityRestrictions($shelf, ['view', 'update']);
-
-        $this->forceVisit($shelf->getUrl('/delete'))
-            ->see('You do not have permission')->seePageIs('/');
-
-        $this->setEntityRestrictions($shelf, ['view', 'delete']);
-
-        $this->visit($shelf->getUrl('/delete'))
-            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
-    }
-
-    public function test_book_create_restriction_override()
-    {
-        $book = Book::first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->viewer)
-            ->visit($bookUrl)
-            ->dontSeeInElement('.actions', 'New Page')
-            ->dontSeeInElement('.actions', 'New Chapter');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_book_update_restriction_override()
-    {
-        $book = Book::first();
-        $bookPage = $book->pages->first();
-        $bookChapter = $book->chapters->first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->viewer)
-            ->visit($bookUrl . '/edit')
-            ->dontSee('Edit Book');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_book_delete_restriction_override()
-    {
-        $book = Book::first();
-        $bookPage = $book->pages->first();
-        $bookChapter = $book->chapters->first();
-
-        $bookUrl = $book->getUrl();
-        $this->actingAs($this->viewer)
-            ->visit($bookUrl . '/delete')
-            ->dontSee('Delete Book');
-
-        $this->setEntityRestrictions($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->setEntityRestrictions($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');
-    }
-
-    public function test_page_visible_if_has_permissions_when_book_not_visible()
-    {
-        $book = Book::first();
-
-        $this->setEntityRestrictions($book, []);
-
-        $bookChapter = $book->chapters->first();
-        $bookPage = $bookChapter->pages->first();
-        $this->setEntityRestrictions($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));
-    }
-
-    public function test_book_sort_view_permission()
-    {
-        $firstBook = Book::first();
-        $secondBook = Book::find(2);
-
-        $this->setEntityRestrictions($firstBook, ['view', 'update']);
-        $this->setEntityRestrictions($secondBook, ['view']);
-
-        // Test sort page visibility
-        $this->actingAs($this->user)->visit($secondBook->getUrl() . '/sort')
-                ->see('You do not have permission')
-                ->seePageIs('/');
-
-        // 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->setEntityRestrictions($firstBook, ['view', 'update']);
-        $this->setEntityRestrictions($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('/');
-    }
-
-    public function test_can_create_page_if_chapter_has_permissions_when_book_not_visible()
-    {
-        $book = Book::first();
-        $this->setEntityRestrictions($book, []);
-        $bookChapter = $book->chapters->first();
-        $this->setEntityRestrictions($bookChapter, ['view']);
-
-        $this->actingAs($this->user)->visit($bookChapter->getUrl())
-            ->dontSee('New Page');
-
-        $this->setEntityRestrictions($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);
-    }
-}
index 99080d354c4c239fae8261e8de53d06681b7e7fe..5248ae1528ffbb509d62c0a2691b9f88aca06c85 100644 (file)
@@ -1,14 +1,21 @@
-<?php namespace Tests\Permissions;
+<?php
 
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Page;
-use BookStack\Auth\Permissions\PermissionsRepo;
-use BookStack\Auth\Role;
-use Laravel\BrowserKitTesting\HttpException;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
-use Tests\BrowserKitTest;
+namespace Tests\Permissions;
 
-class RolesTest extends BrowserKitTest
+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\Uploads\Image;
+use Tests\TestCase;
+use Tests\TestResponse;
+
+class RolesTest extends TestCase
 {
     protected $user;
 
@@ -20,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 = \BookStack\Auth\Role::getRole('admin');
+        $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()
@@ -39,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()
@@ -52,72 +58,126 @@ 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, 'name' => 'test-role', '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, 'name' => 'test-role', '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);
+
+        $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());
 
-        $this->seePageIs($editUrl);
-        $this->see('This user is the only user assigned to the administrator role');
+        $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,
+        ]);
+
+        $this->assertCount(1, $roleA->users()->get());
+        $this->assertEquals($this->user->id, $roleA->users()->first()->id);
     }
 
     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);
         $this->giveUserPermissions($this->user, ['users-manage']);
-        $this->actingAs($this->user)->visit('/')->see($usersLink);
+        $this->actingAs($this->user)->get('/')->assertSee($usersLink);
         $this->giveUserPermissions($this->user, ['settings-manage', 'users-manage']);
-        $this->actingAs($this->user)->visit('/')->dontSee($usersLink);
+        $this->actingAs($this->user)->get('/')->assertDontSee($usersLink);
     }
 
     public function test_user_cannot_change_email_unless_they_have_manage_users_permission()
@@ -126,397 +186,393 @@ 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 = \BookStack\Entities\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 = \BookStack\Entities\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
+        // that the owner fields are checked
+        $page = $content['page']; /** @var Page $page */
+        $page->created_by = $otherUsersPage->id;
+        $page->owned_by = $this->user->id;
+        $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($content['page']->getUrl())
-            ->dontSee('Permissions')
-            ->visit($content['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($content['page']->getUrl())
-            ->see('Permissions')
-            ->click('Permissions')
-            ->seePageIs($content['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(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $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 = \BookStack\Entities\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 = \BookStack\Entities\Bookshelf::first();
+        /** @var Bookshelf $otherShelf */
+        $otherShelf = Bookshelf::query()->first();
         $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
-        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\Book::first();
-        $chapter = \BookStack\Entities\Chapter::first();
+        /** @var Book $book */
+        $book = Book::query()->first();
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
 
         $entities = $this->createEntityChainBelongingToUser($this->user);
         $ownBook = $entities['book'];
@@ -527,356 +583,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 = \BookStack\Entities\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 = \BookStack\Entities\Book::take(1)->get()->first();
-        $chapter = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Entities\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 = \BookStack\Auth\User::first();
-        $this->asAdmin()->visit('/settings/users/' . $user->id)
-            ->seeElement('[name="roles[admin]"]')
-            ->seeElement('[name="roles[public]"]');
+        /** @var User $user */
+        $user = User::query()->first();
+        $adminRole = Role::getSystemRole('admin');
+        $publicRole = Role::getSystemRole('public');
+        $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-role-name="admin"]')
-            ->seeElement('[data-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 = \BookStack\Entities\Page::first();
-        $image = factory(\BookStack\Uploads\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 = factory(Image::class)->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 = \BookStack\Entities\Page::first();
-        $image = factory(\BookStack\Uploads\Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $image = factory(Image::class)->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();
-        $viewerRole = \BookStack\Auth\Role::getRole('viewer');
+        /** @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(\BookStack\Entities\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 = factory(Book::class)->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(\BookStack\Entities\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 = factory(Chapter::class)->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(\BookStack\Actions\Comment::class)->make();
-        $url = "/ajax/page/$page->id/comment";
-        $request = [
-            'text' => $comment->text,
-            'html' => $comment->html
-        ];
+    private function addComment(Page $page): TestResponse
+    {
+        $comment = factory(Comment::class)->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(\BookStack\Actions\Comment::class)->make();
-        $url = "/ajax/comment/$commentId";
-        $request = [
-            'text' => $comment->text,
-            'html' => $comment->html
-        ];
+    private function updateComment(Comment $comment): TestResponse
+    {
+        $commentData = factory(Comment::class)->make();
 
-        return $this->putJson($url, $request);
+        return $this->putJson("/comment/{$comment->id}", $commentData->only('text', 'html'));
     }
 
-    private function deleteComment($commentId) {
-         $url = '/ajax/comment/' . $commentId;
-         return $this->json('DELETE', $url);
+    private function deleteComment(Comment $comment): TestResponse
+    {
+        return $this->json('DELETE', '/comment/' . $comment->id);
     }
-
 }
index 3670df87d39df58e28ea7383955e3f9db473ab7f..499c0c9f9710ab0bbd4c6f7401743c325dc2c6be 100644 (file)
@@ -1,79 +1,83 @@
-<?php namespace Tests;
+<?php
+
+namespace Tests;
 
-use Auth;
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Auth\Permissions\RolePermission;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+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 BrowserKitTest
+class PublicActionTest extends TestCase
 {
-
     public function test_app_not_public()
     {
         $this->setSettings(['app-public' => 'false']);
-        $book = Book::orderBy('name', 'asc')->first();
-        $this->visit('/books')->seePageIs('/login');
-        $this->visit($book->getUrl())->seePageIs('/login');
+        $book = Book::query()->first();
+        $this->get('/books')->assertRedirect('/login');
+        $this->get($book->getUrl())->assertRedirect('/login');
 
-        $page = Page::first();
-        $this->visit($page->getUrl())->seePageIs('/login');
+        $page = Page::query()->first();
+        $this->get($page->getUrl())->assertRedirect('/login');
     }
 
     public function test_login_link_visible()
     {
         $this->setSettings(['app-public' => 'true']);
-        $this->visit('/')->see(url('/login'));
+        $this->get('/')->assertElementExists('a[href="' . url('/login') . '"]');
     }
 
     public function test_register_link_visible_when_enabled()
     {
         $this->setSettings(['app-public' => 'true']);
-
-        $this->visit('/')->see(url('/login'));
-        $this->visit('/')->dontSee(url('/register'));
+        $home = $this->get('/');
+        $home->assertSee(url('/login'));
+        $home->assertDontSee(url('/register'));
 
         $this->setSettings(['app-public' => 'true', 'registration-enabled' => 'true']);
-        $this->visit('/')->see(url('/login'));
-        $this->visit('/')->see(url('/register'));
+        $home = $this->get('/');
+        $home->assertSee(url('/login'));
+        $home->assertSee(url('/register'));
     }
 
     public function test_books_viewable()
     {
         $this->setSettings(['app-public' => 'true']);
-        $books = Book::orderBy('name', 'asc')->take(10)->get();
+        $books = Book::query()->orderBy('name', 'asc')->take(10)->get();
         $bookToVisit = $books[1];
 
         // Check books index page is showing
-        $this->visit('/books')
-            ->seeStatusCode(200)
-            ->see($books[0]->name)
-            // Check individual book page is showing and it's child contents are visible.
-            ->click($bookToVisit->name)
-            ->seePageIs($bookToVisit->getUrl())
-            ->see($bookToVisit->name)
-            ->see($bookToVisit->chapters()->first()->name);
+        $resp = $this->get('/books');
+        $resp->assertStatus(200);
+        $resp->assertSee($books[0]->name);
+
+        // Check individual book page is showing and it's child contents are visible.
+        $resp = $this->get($bookToVisit->getUrl());
+        $resp->assertSee($bookToVisit->name);
+        $resp->assertSee($bookToVisit->chapters()->first()->name);
     }
 
     public function test_chapters_viewable()
     {
         $this->setSettings(['app-public' => 'true']);
-        $chapterToVisit = Chapter::first();
+        /** @var Chapter $chapterToVisit */
+        $chapterToVisit = Chapter::query()->first();
         $pageToVisit = $chapterToVisit->pages()->first();
 
         // Check chapters index page is showing
-        $this->visit($chapterToVisit->getUrl())
-            ->seeStatusCode(200)
-            ->see($chapterToVisit->name)
-            // Check individual chapter page is showing and it's child contents are visible.
-            ->see($pageToVisit->name)
-            ->click($pageToVisit->name)
-            ->see($chapterToVisit->book->name)
-            ->see($chapterToVisit->name)
-            ->seePageIs($pageToVisit->getUrl());
+        $resp = $this->get($chapterToVisit->getUrl());
+        $resp->assertStatus(200);
+        $resp->assertSee($chapterToVisit->name);
+        // Check individual chapter page is showing and it's child contents are visible.
+        $resp->assertSee($pageToVisit->name);
+        $resp = $this->get($pageToVisit->getUrl());
+        $resp->assertStatus(200);
+        $resp->assertSee($chapterToVisit->book->name);
+        $resp->assertSee($chapterToVisit->name);
     }
 
     public function test_public_page_creation()
@@ -87,97 +91,98 @@ class PublicActionTest extends BrowserKitTest
         }
         $this->app[PermissionService::class]->buildJointPermissionForRole($publicRole);
 
-        $chapter = Chapter::first();
-        $this->visit($chapter->book->getUrl());
-        $this->visit($chapter->getUrl())
-            ->click('New Page')
-            ->see('New Page')
-            ->seePageIs($chapter->getUrl('/create-page'));
+        /** @var Chapter $chapter */
+        $chapter = Chapter::query()->first();
+        $resp = $this->get($chapter->getUrl());
+        $resp->assertSee('New 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') . '"]');
 
-        $this->submitForm('Continue', [
-            'name' => 'My guest page'
-        ])->seePageIs($chapter->book->getUrl('/page/my-guest-page/edit'));
+        $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->seeInDatabase('pages', [
-            'name' => 'My guest page',
+        $this->assertDatabaseHas('pages', [
+            'name'       => 'My guest page',
             'chapter_id' => $chapter->id,
             'created_by' => $user->id,
-            'updated_by' => $user->id
+            'updated_by' => $user->id,
         ]);
     }
 
     public function test_content_not_listed_on_404_for_public_users()
     {
-        $page = Page::first();
-        $this->asAdmin()->visit($page->getUrl());
+        $page = Page::query()->first();
+        $page->fill(['name' => 'my testing random unique page name'])->save();
+        $this->asAdmin()->get($page->getUrl()); // Fake visit to show on recents
+        $resp = $this->get('/cats/dogs/hippos');
+        $resp->assertStatus(404);
+        $resp->assertSee($page->name);
+        View::share('pageTitle', '');
+
         Auth::logout();
-        view()->share('pageTitle', '');
-        $this->forceVisit('/cats/dogs/hippos');
-        $this->dontSee($page->name);
+        $resp = $this->get('/cats/dogs/hippos');
+        $resp->assertStatus(404);
+        $resp->assertDontSee($page->name);
     }
 
     public function test_robots_effected_by_public_status()
     {
-        $this->visit('/robots.txt');
-        $this->seeText("User-agent: *\nDisallow: /");
+        $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /");
 
         $this->setSettings(['app-public' => 'true']);
-        $this->visit('/robots.txt');
 
-        $this->seeText("User-agent: *\nDisallow:");
-        $this->dontSeeText("Disallow: /");
+        $resp = $this->get('/robots.txt');
+        $resp->assertSee("User-agent: *\nDisallow:");
+        $resp->assertDontSee('Disallow: /');
     }
 
     public function test_robots_effected_by_setting()
     {
-        $this->visit('/robots.txt');
-        $this->seeText("User-agent: *\nDisallow: /");
+        $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /");
 
         config()->set('app.allow_robots', true);
-        $this->visit('/robots.txt');
 
-        $this->seeText("User-agent: *\nDisallow:");
-        $this->dontSeeText("Disallow: /");
+        $resp = $this->get('/robots.txt');
+        $resp->assertSee("User-agent: *\nDisallow:");
+        $resp->assertDontSee('Disallow: /');
 
         // Check config overrides app-public setting
         config()->set('app.allow_robots', false);
         $this->setSettings(['app-public' => 'true']);
-        $this->visit('/robots.txt');
-
-        $this->seeText("User-agent: *\nDisallow: /");
+        $this->get('/robots.txt')->assertSee("User-agent: *\nDisallow: /");
     }
 
     public function test_public_view_then_login_redirects_to_previous_content()
     {
         $this->setSettings(['app-public' => 'true']);
+        /** @var Book $book */
         $book = Book::query()->first();
-        $this->visit($book->getUrl())
-            ->see($book->name)
-            ->visit('/login')
-            ->type('[email protected]', '#email')
-            ->type('password', '#password')
-            ->press('Log In')
-            ->seePageUrlIs($book->getUrl());
+        $resp = $this->get($book->getUrl());
+        $resp->assertSee($book->name);
+
+        $this->get('/login');
+        $resp = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
+        $resp->assertRedirect($book->getUrl());
     }
 
     public function test_access_hidden_content_then_login_redirects_to_intended_content()
     {
         $this->setSettings(['app-public' => 'true']);
+        /** @var Book $book */
         $book = Book::query()->first();
         $this->setEntityRestrictions($book);
 
-        try {
-            $this->visit($book->getUrl());
-        } catch (\Exception $exception) {}
-
-        $this->see('Book not found')
-            ->dontSee($book->name)
-            ->visit('/login')
-            ->type('[email protected]', '#email')
-            ->type('password', '#password')
-            ->press('Log In')
-            ->seePageUrlIs($book->getUrl())
-            ->see($book->name);
+        $resp = $this->get($book->getUrl());
+        $resp->assertSee('Book not found');
+
+        $this->get('/login');
+        $resp = $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
+        $resp->assertRedirect($book->getUrl());
+        $this->followRedirects($resp)->assertSee($book->name);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/RecycleBinTest.php b/tests/RecycleBinTest.php
new file mode 100644 (file)
index 0000000..f3e30c0
--- /dev/null
@@ -0,0 +1,269 @@
+<?php
+
+namespace Tests;
+
+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\Models\Page;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class RecycleBinTest extends TestCase
+{
+    public function test_recycle_bin_routes_permissions()
+    {
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+        $this->actingAs($editor)->delete($page->getUrl());
+        $deletion = Deletion::query()->firstOrFail();
+
+        $routes = [
+            'GET:/settings/recycle-bin',
+            'POST:/settings/recycle-bin/empty',
+            "GET:/settings/recycle-bin/{$deletion->id}/destroy",
+            "GET:/settings/recycle-bin/{$deletion->id}/restore",
+            "POST:/settings/recycle-bin/{$deletion->id}/restore",
+            "DELETE:/settings/recycle-bin/{$deletion->id}",
+        ];
+
+        foreach ($routes as $route) {
+            [$method, $url] = explode(':', $route);
+            $resp = $this->call($method, $url);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->giveUserPermissions($editor, ['restrictions-manage-all']);
+
+        foreach ($routes as $route) {
+            [$method, $url] = explode(':', $route);
+            $resp = $this->call($method, $url);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->giveUserPermissions($editor, ['settings-manage']);
+
+        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()
+    {
+        $page = Page::query()->first();
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
+        $editor = $this->getEditor();
+        $this->actingAs($editor)->delete($page->getUrl());
+        $this->actingAs($editor)->delete($book->getUrl());
+
+        $viewReq = $this->asAdmin()->get('/settings/recycle-bin');
+        $viewReq->assertElementContains('table.table', $page->name);
+        $viewReq->assertElementContains('table.table', $editor->name);
+        $viewReq->assertElementContains('table.table', $book->name);
+        $viewReq->assertElementContains('table.table', $book->pages_count . ' Pages');
+        $viewReq->assertElementContains('table.table', $book->chapters_count . ' Chapters');
+    }
+
+    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();
+        $editor = $this->getEditor();
+        $this->actingAs($editor)->delete($page->getUrl());
+        $this->actingAs($editor)->delete($book->getUrl());
+
+        $this->assertTrue(Deletion::query()->count() === 2);
+        $emptyReq = $this->asAdmin()->post('/settings/recycle-bin/empty');
+        $emptyReq->assertRedirect('/settings/recycle-bin');
+
+        $this->assertTrue(Deletion::query()->count() === 0);
+        $this->assertDatabaseMissing('books', ['id' => $book->id]);
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+
+        $itemCount = 2 + $book->pages->count() + $book->chapters->count();
+        $redirectReq = $this->get('/settings/recycle-bin');
+        $redirectReq->assertNotificationContains('Deleted ' . $itemCount . ' total items from the recycle bin');
+    }
+
+    public function test_entity_restore()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $this->asEditor()->delete($book->getUrl());
+        $deletion = Deletion::query()->firstOrFail();
+
+        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+
+        $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
+        $restoreReq->assertRedirect('/settings/recycle-bin');
+        $this->assertTrue(Deletion::query()->count() === 0);
+
+        $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+        $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+
+        $itemCount = 1 + $book->pages->count() + $book->chapters->count();
+        $redirectReq = $this->get('/settings/recycle-bin');
+        $redirectReq->assertNotificationContains('Restored ' . $itemCount . ' total items from the recycle bin');
+    }
+
+    public function test_permanent_delete()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $this->asEditor()->delete($book->getUrl());
+        $deletion = Deletion::query()->firstOrFail();
+
+        $deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
+        $deleteReq->assertRedirect('/settings/recycle-bin');
+        $this->assertTrue(Deletion::query()->count() === 0);
+
+        $this->assertDatabaseMissing('books', ['id' => $book->id]);
+        $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
+        $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+
+        $itemCount = 1 + $book->pages->count() + $book->chapters->count();
+        $redirectReq = $this->get('/settings/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) {
+            $entity = $entity->newQuery()->first();
+            $this->asEditor()->delete($entity->getUrl());
+            $deletion = Deletion::query()->orderBy('id', 'desc')->firstOrFail();
+
+            $deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
+            $deleteReq->assertRedirect('/settings/recycle-bin');
+            $this->assertDatabaseMissing('deletions', ['id' => $deletion->id]);
+            $this->assertDatabaseMissing($entity->getTable(), ['id' => $entity->id]);
+        }
+    }
+
+    public function test_permanent_entity_delete_updates_existing_activity_with_entity_name()
+    {
+        $page = Page::query()->firstOrFail();
+        $this->asEditor()->delete($page->getUrl());
+        $deletion = $page->deletions()->firstOrFail();
+
+        $this->assertDatabaseHas('activities', [
+            '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,
+            'entity_type' => $page->getMorphClass(),
+        ]);
+
+        $this->assertDatabaseHas('activities', [
+            'type'        => 'page_delete',
+            'entity_id'   => null,
+            'entity_type' => null,
+            'detail'      => $page->name,
+        ]);
+    }
+
+    public function test_auto_clear_functionality_works()
+    {
+        config()->set('app.recycle_bin_lifetime', 5);
+        $page = Page::query()->firstOrFail();
+        $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+        $this->assertEquals(1, Deletion::query()->count());
+
+        Carbon::setTestNow(Carbon::now()->addDays(6));
+        $this->asEditor()->delete($otherPage->getUrl());
+        $this->assertEquals(1, Deletion::query()->count());
+
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+    }
+
+    public function test_auto_clear_functionality_with_negative_time_keeps_forever()
+    {
+        config()->set('app.recycle_bin_lifetime', -1);
+        $page = Page::query()->firstOrFail();
+        $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertEquals(1, Deletion::query()->count());
+
+        Carbon::setTestNow(Carbon::now()->addDays(6000));
+        $this->asEditor()->delete($otherPage->getUrl());
+        $this->assertEquals(2, Deletion::query()->count());
+
+        $this->assertDatabaseHas('pages', ['id' => $page->id]);
+    }
+
+    public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
+    {
+        config()->set('app.recycle_bin_lifetime', 0);
+        $page = Page::query()->firstOrFail();
+
+        $this->asEditor()->delete($page->getUrl());
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+        $this->assertEquals(0, Deletion::query()->count());
+    }
+
+    public function test_restore_flow_when_restoring_nested_delete_first()
+    {
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+        $chapter = $book->chapters->first();
+        $this->asEditor()->delete($chapter->getUrl());
+        $this->asEditor()->delete($book->getUrl());
+
+        $bookDeletion = $book->deletions()->first();
+        $chapterDeletion = $chapter->deletions()->first();
+
+        $chapterRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$chapterDeletion->id}/restore");
+        $chapterRestoreView->assertStatus(200);
+        $chapterRestoreView->assertSeeText($chapter->name);
+
+        $chapterRestore = $this->post("/settings/recycle-bin/{$chapterDeletion->id}/restore");
+        $chapterRestore->assertRedirect('/settings/recycle-bin');
+        $this->assertDatabaseMissing('deletions', ['id' => $chapterDeletion->id]);
+
+        $chapter->refresh();
+        $this->assertNotNull($chapter->deleted_at);
+
+        $bookRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$bookDeletion->id}/restore");
+        $bookRestoreView->assertStatus(200);
+        $bookRestoreView->assertSeeText($chapter->name);
+
+        $this->post("/settings/recycle-bin/{$bookDeletion->id}/restore");
+        $chapter->refresh();
+        $this->assertNull($chapter->deleted_at);
+    }
+
+    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');
+    }
+}
diff --git a/tests/SecurityHeaderTest.php b/tests/SecurityHeaderTest.php
new file mode 100644 (file)
index 0000000..2bde890
--- /dev/null
@@ -0,0 +1,133 @@
+<?php
+
+namespace Tests;
+
+use BookStack\Util\CspService;
+
+class SecurityHeaderTest extends TestCase
+{
+    public function test_cookies_samesite_lax_by_default()
+    {
+        $resp = $this->get('/');
+        foreach ($resp->headers->getCookies() as $cookie) {
+            $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('/');
+            foreach ($resp->headers->getCookies() as $cookie) {
+                $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('/');
+            foreach ($resp->headers->getCookies() as $cookie) {
+                $this->assertFalse($cookie->isSecure());
+            }
+        });
+
+        $this->runWithEnv('APP_URL', 'https://example.com', function () {
+            $resp = $this->get('/');
+            foreach ($resp->headers->getCookies() as $cookie) {
+                $this->assertTrue($cookie->isSecure());
+            }
+        });
+    }
+
+    public function test_iframe_csp_self_only_by_default()
+    {
+        $resp = $this->get('/');
+        $frameHeader = $this->getCspHeader($resp, 'frame-ancestors');
+
+        $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('/');
+            $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>');
+    }
+
+    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);
+    }
+
+    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);
+    }
+
+    /**
+     * 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..94ef471
--- /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")');
+    }
+
+    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")');
+    }
+
+    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>');
+    }
+
+    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);
+    }
+}
diff --git a/tests/Settings/FooterLinksTest.php b/tests/Settings/FooterLinksTest.php
new file mode 100644 (file)
index 0000000..55c3e10
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+namespace Tests\Settings;
+
+use Tests\TestCase;
+
+class FooterLinksTest extends TestCase
+{
+    public function test_saving_setting()
+    {
+        $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'],
+            ],
+        ]);
+        $resp->assertRedirect('/settings');
+
+        $result = setting('app-footer-links');
+        $this->assertIsArray($result);
+        $this->assertCount(2, $result);
+        $this->assertEquals('My custom link 2', $result[1]['label']);
+        $this->assertEquals('https://example.com/1', $result[0]['url']);
+    }
+
+    public function test_set_options_visible_on_settings_page()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'My custom link', 'url' => 'https://example.com/link-a'],
+            ['label' => 'Another Link', 'url' => 'https://example.com/link-b'],
+        ]]);
+
+        $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"');
+    }
+
+    public function test_footer_links_show_on_pages()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'My custom link', 'url' => 'https://example.com/link-a'],
+            ['label' => 'Another Link', 'url' => 'https://example.com/link-b'],
+        ]]);
+
+        $this->get('/login')->assertElementContains('footer a[href="https://example.com/link-a"]', 'My custom link');
+        $this->asEditor()->get('/')->assertElementContains('footer a[href="https://example.com/link-b"]', 'Another link');
+    }
+
+    public function test_using_translation_system_for_labels()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'trans::common.privacy_policy', 'url' => 'https://example.com/privacy'],
+            ['label' => 'trans::common.terms_of_service', 'url' => 'https://example.com/terms'],
+        ]]);
+
+        $resp = $this->get('/login');
+        $resp->assertElementContains('footer a[href="https://example.com/privacy"]', 'Privacy Policy');
+        $resp->assertElementContains('footer a[href="https://example.com/terms"]', 'Terms of Service');
+    }
+}
index c7659a02dabae0168348553d4f0efd360f5598d9..e4d27c849e7a993ea34d3a2333fea62d6f260e51 100644 (file)
@@ -1,36 +1,38 @@
-<?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\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+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\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 Illuminate\Foundation\Testing\Assert as PHPUnit;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Env;
 use Illuminate\Support\Facades\Log;
 use Mockery;
 use Monolog\Handler\TestHandler;
 use Monolog\Logger;
-use Throwable;
 
 trait SharedTestHelpers
 {
-
     protected $admin;
     protected $editor;
 
     /**
      * Set the current user context to be an admin.
-     * @return $this
      */
     public function asAdmin()
     {
@@ -39,35 +41,35 @@ trait SharedTestHelpers
 
     /**
      * Get the current admin user.
-     * @return mixed
      */
-    public function getAdmin() {
-        if($this->admin === null) {
+    public function getAdmin(): User
+    {
+        if (is_null($this->admin)) {
             $adminRole = Role::getSystemRole('admin');
             $this->admin = $adminRole->users->first();
         }
+
         return $this->admin;
     }
 
     /**
      * Set the current user context to be an editor.
-     * @return $this
      */
     public function asEditor()
     {
         return $this->actingAs($this->getEditor());
     }
 
-
     /**
      * Get a editor user.
-     * @return mixed
      */
-    protected function getEditor() {
-        if($this->editor === null) {
+    protected function getEditor(): User
+    {
+        if ($this->editor === null) {
             $editorRole = Role::getRole('editor');
             $this->editor = $editorRole->users->first();
         }
+
         return $this->editor;
     }
 
@@ -80,15 +82,22 @@ 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.
-     * @param Entity $entity
-     * @throws Throwable
      */
-    protected function regenEntityPermissions(Entity $entity)
+    protected function regenEntityPermissions(Entity $entity): void
     {
         $entity->rebuildPermissions();
         $entity->load('jointPermissions');
@@ -96,50 +105,44 @@ trait SharedTestHelpers
 
     /**
      * Create and return a new bookshelf.
-     * @param array $input
-     * @return Bookshelf
      */
-    public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) {
+    public function newShelf(array $input = ['name' => 'test shelf', 'description' => 'My new test shelf']): Bookshelf
+    {
         return app(BookshelfRepo::class)->create($input, []);
     }
 
     /**
      * Create and return a new book.
-     * @param array $input
-     * @return Book
      */
-    public function newBook($input = ['name' => 'test book', 'description' => 'My new test book']) {
+    public function newBook(array $input = ['name' => 'test book', 'description' => 'My new test book']): Book
+    {
         return app(BookRepo::class)->create($input);
     }
 
     /**
-     * Create and return a new test chapter
-     * @param array $input
-     * @param Book $book
-     * @return Chapter
+     * Create and return a new test chapter.
      */
-    public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) {
+    public function newChapter(array $input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book): Chapter
+    {
         return app(ChapterRepo::class)->create($input, $book);
     }
 
     /**
-     * Create and return a new test page
-     * @param array $input
-     * @return Page
-     * @throws Throwable
+     * Create and return a new test page.
      */
-    public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) {
-        $book = Book::first();
+    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);
     }
 
     /**
      * Quickly sets an array of settings.
-     * @param $settingsArray
      */
-    protected function setSettings($settingsArray)
+    protected function setSettings(array $settingsArray): void
     {
         $settings = app(SettingService::class);
         foreach ($settingsArray as $key => $value) {
@@ -149,11 +152,8 @@ trait SharedTestHelpers
 
     /**
      * Manually set some permissions on an entity.
-     * @param Entity $entity
-     * @param array $actions
-     * @param array $roles
      */
-    protected function setEntityRestrictions(Entity $entity, $actions = [], $roles = [])
+    protected function setEntityRestrictions(Entity $entity, array $actions = [], array $roles = []): void
     {
         $entity->restricted = true;
         $entity->permissions()->delete();
@@ -163,7 +163,7 @@ trait SharedTestHelpers
             foreach ($roles as $role) {
                 $permissions[] = [
                     'role_id' => $role->id,
-                    'action' => strtolower($action)
+                    'action'  => strtolower($action),
                 ];
             }
         }
@@ -177,34 +177,63 @@ trait SharedTestHelpers
 
     /**
      * Give the given user some permissions.
-     * @param User $user
-     * @param array $permissions
      */
-    protected function giveUserPermissions(User $user, $permissions = [])
+    protected function giveUserPermissions(User $user, array $permissions = []): void
     {
         $newRole = $this->createNewRole($permissions);
         $user->attachRole($newRole);
         $user->load('roles');
-        $user->permissions(false);
+        $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.
-     * @param array $permissions
-     * @return Role
      */
-    protected function createNewRole($permissions = [])
+    protected function createNewRole(array $permissions = []): Role
     {
         $permissionRepo = app(PermissionsRepo::class);
         $roleData = factory(Role::class)->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 = 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');
+    }
+
     /**
      * Mock the HttpFetcher service and return the given data on fetch.
-     * @param $returnData
-     * @param int $times
      */
     protected function mockHttpFetch($returnData, int $times = 1)
     {
@@ -218,9 +247,6 @@ trait SharedTestHelpers
     /**
      * Run a set test with the given env variable.
      * Remembers the original and resets the value after test.
-     * @param string $name
-     * @param $value
-     * @param callable $callback
      */
     protected function runWithEnv(string $name, $value, callable $callback)
     {
@@ -246,11 +272,8 @@ trait SharedTestHelpers
     /**
      * Check the keys and properties in the given map to include
      * exist, albeit not exclusively, within the map to check.
-     * @param array $mapToInclude
-     * @param array $mapToCheck
-     * @param string $message
      */
-    protected function assertArrayMapIncludes(array $mapToInclude, array $mapToCheck, string $message = '') : void
+    protected function assertArrayMapIncludes(array $mapToInclude, array $mapToCheck, string $message = ''): void
     {
         $passed = true;
 
@@ -270,14 +293,34 @@ trait SharedTestHelpers
      */
     protected function assertPermissionError($response)
     {
-        if ($response instanceof BrowserKitTest) {
-            $response = \Illuminate\Foundation\Testing\TestResponse::fromBaseResponse($response->response);
-        }
+        PHPUnit::assertTrue($this->isPermissionError($response->baseResponse ?? $response->response), 'Failed asserting the response contains a permission error.');
+    }
+
+    /**
+     * Assert a permission error has occurred.
+     */
+    protected function assertNotPermissionError($response)
+    {
+        PHPUnit::assertFalse($this->isPermissionError($response->baseResponse ?? $response->response), 'Failed asserting the response does not contain a permission error.');
+    }
 
-        $response->assertRedirect('/');
-        $this->assertSessionHas('error');
-        $error = session()->pull('error');
-        $this->assertStringStartsWith('You do not have permission to access', $error);
+    /**
+     * Check if the given response is a permission error.
+     */
+    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 instanceof JsonResponse &&
+                    $response->json(['error' => 'You do not have permission to perform the requested action.'])
+                )
+            );
     }
 
     /**
@@ -290,12 +333,11 @@ trait SharedTestHelpers
         $testHandler = new TestHandler();
         $monolog->pushHandler($testHandler);
 
-        Log::extend('testing', function() use ($monolog) {
+        Log::extend('testing', function () use ($monolog) {
             return $monolog;
         });
         Log::setDefaultDriver('testing');
 
         return $testHandler;
     }
-
-}
\ No newline at end of file
+}
diff --git a/tests/StatusTest.php b/tests/StatusTest.php
new file mode 100644 (file)
index 0000000..0988275
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Session;
+use Tests\TestCase;
+
+class StatusTest extends TestCase
+{
+    public function test_returns_json_with_expected_results()
+    {
+        $resp = $this->get('/status');
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'database' => true,
+            'cache'    => true,
+            'session'  => true,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_db_error()
+    {
+        DB::shouldReceive('table')->andThrow(new Exception());
+
+        $resp = $this->get('/status');
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'database' => false,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_wrong_cache_return()
+    {
+        $mockStore = Mockery::mock(new ArrayStore())->makePartial();
+        Cache::swap($mockStore);
+        $mockStore->shouldReceive('get')->andReturn('cat');
+
+        $resp = $this->get('/status');
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'cache' => false,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_wrong_session_return()
+    {
+        $session = Session::getFacadeRoot();
+        $mockSession = Mockery::mock($session)->makePartial();
+        Session::swap($mockSession);
+        $mockSession->shouldReceive('get')->andReturn('cat');
+
+        $resp = $this->get('/status');
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'session' => false,
+        ]);
+    }
+}
index 1f1d5ece7288e88575b49975848812bac5915173..98e0dfbacf4c4cfb6321a55d9aa44fe9e491c6ef 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests;
+<?php
 
-use BookStack\Entities\Entity;
+namespace Tests;
+
+use BookStack\Entities\Models\Entity;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
 
@@ -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,15 +62,19 @@ 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 $key, Entity $entity = null)
+    protected function assertActivityExists(string $type, ?Entity $entity = null, string $detail = '')
     {
-        $detailsToCheck = ['key' => $key];
+        $detailsToCheck = ['type' => $type];
 
         if ($entity) {
             $detailsToCheck['entity_type'] = $entity->getMorphClass();
             $detailsToCheck['entity_id'] = $entity->id;
         }
 
+        if ($detail) {
+            $detailsToCheck['detail'] = $detail;
+        }
+
         $this->assertDatabaseHas('activities', $detailsToCheck);
     }
-}
\ No newline at end of file
+}
index 76ff322fff6e0dae8ede4d175ef3e4b46ffa3c6b..0a2091fe3e92bc0dd018acc216087b107e37e7a1 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');
@@ -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 a68a5783fa044c881bfbf8fa39b66355128ae8be..79f173c9b1bee136dee5552093c418eb26cb5488 100644 (file)
@@ -1,33 +1,42 @@
-<?php namespace Tests;
+<?php
 
-use \Illuminate\Foundation\Testing\TestResponse as BaseTestResponse;
-use Symfony\Component\DomCrawler\Crawler;
+namespace Tests;
+
+use Illuminate\Foundation\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;
 
     /**
      * Get the DOM Crawler for the response content.
-     * @return Crawler
      */
-    protected function crawler()
+    protected function crawler(): Crawler
     {
         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.
-     * @param string $selector
+     *
      * @return $this
      */
     public function assertElementExists(string $selector)
@@ -35,17 +44,18 @@ 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 does not contain the specified element.
-     * @param string $selector
+     *
      * @return $this
      */
     public function assertElementNotExists(string $selector)
@@ -53,25 +63,32 @@ 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;
     }
 
     /**
      * Assert the response includes a specific element containing the given text.
-     * @param string $selector
-     * @param string $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)
+    public function assertElementContains(string $selector, string $text, ?int $nthMatch = null)
     {
         $elements = $this->crawler()->filter($selector);
         $matched = false;
         $pattern = $this->getEscapedPattern($text);
+
+        if (!is_null($nthMatch)) {
+            $elements = $elements->eq($nthMatch - 1);
+        }
+
         foreach ($elements as $element) {
             $element = new Crawler($element);
             if (preg_match("/$pattern/i", $element->html())) {
@@ -82,11 +99,12 @@ class TestResponse extends BaseTestResponse {
 
         PHPUnit::assertTrue(
             $matched,
-            'Unable to find element of selector: '.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()}]."
         );
 
@@ -95,15 +113,21 @@ class TestResponse extends BaseTestResponse {
 
     /**
      * Assert the response does not include a specific element containing the given text.
-     * @param string $selector
-     * @param string $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)
+    public function assertElementNotContains(string $selector, string $text, ?int $nthMatch = null)
     {
         $elements = $this->crawler()->filter($selector);
         $matched = false;
         $pattern = $this->getEscapedPattern($text);
+
+        if (!is_null($nthMatch)) {
+            $elements = $elements->eq($nthMatch - 1);
+        }
+
         foreach ($elements as $element) {
             $element = new Crawler($element);
             if (preg_match("/$pattern/i", $element->html())) {
@@ -114,28 +138,39 @@ class TestResponse extends BaseTestResponse {
 
         PHPUnit::assertTrue(
             !$matched,
-            'Found element of selector: '.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()}]."
         );
 
         return $this;
     }
 
+    /**
+     * Assert there's a notification within the view containing the given text.
+     *
+     * @return $this
+     */
+    public function assertNotificationContains(string $text)
+    {
+        return $this->assertElementContains('[notification]', $text);
+    }
+
     /**
      * Get the escaped text pattern for the constraint.
-     * @param  string  $text
+     *
      * @return string
      */
-    protected function getEscapedPattern($text)
+    protected function getEscapedPattern(string $text)
     {
         $rawPattern = preg_quote($text, '/');
         $escapedPattern = preg_quote(e($text), '/');
+
         return $rawPattern == $escapedPattern
             ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
     }
-
 }
index 51fdfe70d56ebf8797feef5e0577a945b693a5e0..2cab765ae4345c6958d2a2e54988dffb8cccab4b 100644 (file)
-<?php namespace Tests;
+<?php
 
-use File;
+namespace Tests;
+
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Support\Facades\File;
+use League\CommonMark\ConfigurableEnvironmentInterface;
 
 class ThemeTest extends TestCase
 {
     protected $themeFolderName;
     protected $themeFolderPath;
 
-    public function setUp(): void
+    public function test_translation_text_can_be_overridden_via_theme()
     {
-        parent::setUp();
+        $this->usingThemeFolder(function () {
+            $translationPath = theme_path('/lang/en');
+            File::makeDirectory($translationPath, 0777, true);
 
-        // Create a folder and configure a theme
-        $this->themeFolderName = 'testing_theme_' . rtrim(base64_encode(time()), "=");
-        config()->set('view.theme', $this->themeFolderName);
-        $this->themeFolderPath = theme_path('');
-        File::makeDirectory($this->themeFolderPath);
+            $customTranslations = '<?php
+            return [\'books\' => \'Sandwiches\'];
+        ';
+            file_put_contents($translationPath . '/entities.php', $customTranslations);
+
+            $homeRequest = $this->actingAs($this->getViewer())->get('/');
+            $homeRequest->assertElementContains('header nav', 'Sandwiches');
+        });
     }
 
-    public function tearDown(): void
+    public function test_theme_functions_file_used_and_app_boot_event_runs()
     {
-        // Cleanup the custom theme folder we created
-        File::deleteDirectory($this->themeFolderPath);
+        $this->usingThemeFolder(function ($themeFolder) {
+            $functionsFile = theme_path('functions.php');
+            app()->alias('cat', 'dog');
+            file_put_contents($functionsFile, "<?php\nTheme::listen(\BookStack\Theming\ThemeEvents::APP_BOOT, function(\$app) { \$app->alias('cat', 'dog');});");
+            $this->runWithEnv('APP_THEME', $themeFolder, function () {
+                $this->assertEquals('cat', $this->app->getAlias('dog'));
+            });
+        });
+    }
 
-        parent::tearDown();
+    public function test_event_commonmark_environment_configure()
+    {
+        $callbackCalled = false;
+        $callback = function ($environment) use (&$callbackCalled) {
+            $this->assertInstanceOf(ConfigurableEnvironmentInterface::class, $environment);
+            $callbackCalled = true;
+
+            return $environment;
+        };
+        Theme::listen(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $callback);
+
+        $page = Page::query()->first();
+        $content = new PageContent($page);
+        $content->setNewMarkdown('# test');
+
+        $this->assertTrue($callbackCalled);
     }
 
-    public function test_translation_text_can_be_overriden_via_theme()
+    public function test_event_web_middleware_before()
     {
-        $translationPath = theme_path('/lang/en');
-        File::makeDirectory($translationPath, 0777, true);
+        $callbackCalled = false;
+        $requestParam = null;
+        $callback = function ($request) use (&$callbackCalled, &$requestParam) {
+            $requestParam = $request;
+            $callbackCalled = true;
+        };
 
-        $customTranslations = '<?php
-            return [\'books\' => \'Sandwiches\'];
-        ';
-        file_put_contents($translationPath . '/entities.php', $customTranslations);
+        Theme::listen(ThemeEvents::WEB_MIDDLEWARE_BEFORE, $callback);
+        $this->get('/login', ['Donkey' => 'cat']);
+
+        $this->assertTrue($callbackCalled);
+        $this->assertInstanceOf(Request::class, $requestParam);
+        $this->assertEquals('cat', $requestParam->header('donkey'));
+    }
+
+    public function test_event_web_middleware_before_return_val_used_as_response()
+    {
+        $callback = function (Request $request) {
+            return response('cat', 412);
+        };
+
+        Theme::listen(ThemeEvents::WEB_MIDDLEWARE_BEFORE, $callback);
+        $resp = $this->get('/login', ['Donkey' => 'cat']);
+        $resp->assertSee('cat');
+        $resp->assertStatus(412);
+    }
+
+    public function test_event_web_middleware_after()
+    {
+        $callbackCalled = false;
+        $requestParam = null;
+        $responseParam = null;
+        $callback = function ($request, Response $response) use (&$callbackCalled, &$requestParam, &$responseParam) {
+            $requestParam = $request;
+            $responseParam = $response;
+            $callbackCalled = true;
+            $response->header('donkey', 'cat123');
+        };
+
+        Theme::listen(ThemeEvents::WEB_MIDDLEWARE_AFTER, $callback);
+
+        $resp = $this->get('/login', ['Donkey' => 'cat']);
+        $this->assertTrue($callbackCalled);
+        $this->assertInstanceOf(Request::class, $requestParam);
+        $this->assertInstanceOf(Response::class, $responseParam);
+        $resp->assertHeader('donkey', 'cat123');
+    }
+
+    public function test_event_web_middleware_after_return_val_used_as_response()
+    {
+        $callback = function () {
+            return response('cat456', 443);
+        };
+
+        Theme::listen(ThemeEvents::WEB_MIDDLEWARE_AFTER, $callback);
+
+        $resp = $this->get('/login', ['Donkey' => 'cat']);
+        $resp->assertSee('cat456');
+        $resp->assertStatus(443);
+    }
+
+    public function test_event_auth_login_standard()
+    {
+        $args = [];
+        $callback = function (...$eventArgs) use (&$args) {
+            $args = $eventArgs;
+        };
+
+        Theme::listen(ThemeEvents::AUTH_LOGIN, $callback);
+        $this->post('/login', ['email' => '[email protected]', 'password' => 'password']);
+
+        $this->assertCount(2, $args);
+        $this->assertEquals('standard', $args[0]);
+        $this->assertInstanceOf(User::class, $args[1]);
+    }
+
+    public function test_event_auth_register_standard()
+    {
+        $args = [];
+        $callback = function (...$eventArgs) use (&$args) {
+            $args = $eventArgs;
+        };
+        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']);
+
+        $this->assertCount(2, $args);
+        $this->assertEquals('standard', $args[0]);
+        $this->assertInstanceOf(User::class, $args[1]);
+    }
+
+    public function test_add_social_driver()
+    {
+        Theme::addSocialDriver('catnet', [
+            'client_id'     => 'abc123',
+            'client_secret' => 'def456',
+        ], 'SocialiteProviders\Discord\DiscordExtendSocialite@handleTesting');
+
+        $this->assertEquals('catnet', config('services.catnet.name'));
+        $this->assertEquals('abc123', config('services.catnet.client_id'));
+        $this->assertEquals(url('/login/service/catnet/callback'), config('services.catnet.redirect'));
 
-        $homeRequest = $this->actingAs($this->getViewer())->get('/');
-        $homeRequest->assertElementContains('header nav', 'Sandwiches');
+        $loginResp = $this->get('/login');
+        $loginResp->assertSee('login/service/catnet');
     }
 
-}
\ No newline at end of file
+    public function test_add_social_driver_uses_name_in_config_if_given()
+    {
+        Theme::addSocialDriver('catnet', [
+            'client_id'     => 'abc123',
+            'client_secret' => 'def456',
+            'name'          => 'Super Cat Name',
+        ], 'SocialiteProviders\Discord\DiscordExtendSocialite@handleTesting');
+
+        $this->assertEquals('Super Cat Name', config('services.catnet.name'));
+        $loginResp = $this->get('/login');
+        $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);
+    }
+
+    protected function usingThemeFolder(callable $callback)
+    {
+        // Create a folder and configure a theme
+        $themeFolderName = 'testing_theme_' . rtrim(base64_encode(time()), '=');
+        config()->set('view.theme', $themeFolderName);
+        $themeFolderPath = theme_path('');
+        File::makeDirectory($themeFolderPath);
+
+        call_user_func($callback, $themeFolderName);
+
+        // Cleanup the custom theme folder we created
+        File::deleteDirectory($themeFolderPath);
+    }
+}
index 69b737d7df4ac3a88b03b0fcbffa07467d516a6b..207fb7f59e3865aa607a6a36dc66696f1c63f835 100644 (file)
@@ -1,20 +1,20 @@
-<?php namespace Tests\Unit;
+<?php
 
+namespace Tests\Unit;
+
+use Illuminate\Support\Facades\Log;
 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');
         });
@@ -22,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');
         });
@@ -36,20 +36,63 @@ class ConfigTest extends TestCase
         $this->checkEnvConfigResult('APP_URL', $oldDefault, 'app.url', '');
     }
 
+    public function test_errorlog_plain_webserver_channel()
+    {
+        // We can't full test this due to it being targeted for the SAPI logging handler
+        // so we just overwrite that component so we can capture the error log output.
+        config()->set([
+            'logging.channels.errorlog_plain_webserver.handler_with' => [0],
+        ]);
+
+        $temp = tempnam(sys_get_temp_dir(), 'bs-test');
+        $original = ini_set('error_log', $temp);
+
+        Log::channel('errorlog_plain_webserver')->info('Aww, look, a cute puppy');
+
+        ini_set('error_log', $original);
+
+        $output = file_get_contents($temp);
+        $this->assertStringContainsString('Aww, look, a cute puppy', $output);
+        $this->assertStringNotContainsString('INFO', $output);
+        $this->assertStringNotContainsString('info', $output);
+        $this->assertStringNotContainsString('testing', $output);
+    }
+
+    public function test_session_cookie_uses_sub_path_from_app_url()
+    {
+        $this->checkEnvConfigResult('APP_URL', 'https://example.com', 'session.path', '/');
+        $this->checkEnvConfigResult('APP_URL', 'https://a.com/b', 'session.path', '/b');
+        $this->checkEnvConfigResult('APP_URL', 'https://a.com/b/d/e', 'session.path', '/b/d/e');
+        $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);
+    }
+
     /**
      * 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 string $envName
-     * @param string|null $envVal
-     * @param string $configKey
-     * @param string $expectedResult
+     *
+     * @param mixed $expectedResult
      */
-    protected function checkEnvConfigResult(string $envName, $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
+}
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 e98a90b35d2034aaa701a726a42fcb5de4939b30..2248bc2c5d15a3833ac129fb9bb6ae007e30b191 100644 (file)
@@ -1,58 +1,73 @@
-<?php namespace Tests\Uploads;
+<?php
 
+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\Page;
-use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Uploads\AttachmentService;
+use Illuminate\Http\UploadedFile;
 use Tests\TestCase;
 
 class AttachmentTest extends TestCase
 {
     /**
-     * Get a test file that can be uploaded
-     * @param $fileName
-     * @return \Illuminate\Http\UploadedFile
+     * Get a test file that can be uploaded.
      */
-    protected function getTestFile($fileName)
+    protected function getTestFile(string $fileName): UploadedFile
     {
-        return new \Illuminate\Http\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', 55, null, true);
     }
 
     /**
      * Uploads a file with the given name.
-     * @param $name
-     * @param int $uploadedTo
-     * @return \Illuminate\Foundation\Testing\TestResponse
      */
-    protected function uploadFile($name, $uploadedTo = 0)
+    protected function uploadFile(string $name, int $uploadedTo = 0): \Illuminate\Foundation\Testing\TestResponse
     {
         $file = $this->getTestFile($name);
+
         return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
     }
 
+    /**
+     * 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_uploaded_to' => $page->id,
+        ]);
+
+        return Attachment::query()->latest()->first();
+    }
+
     /**
      * Delete all uploaded files.
      * To assist with cleanup.
      */
     protected function deleteUploads()
     {
-        $fileService = $this->app->make(\BookStack\Uploads\AttachmentService::class);
-        foreach (\BookStack\Uploads\Attachment::all() as $file) {
+        $fileService = $this->app->make(AttachmentService::class);
+        foreach (Attachment::all() as $file) {
             $fileService->deleteFile($file);
         }
     }
 
     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,
         ];
@@ -71,10 +86,9 @@ 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);
 
@@ -85,7 +99,7 @@ class AttachmentTest extends TestCase
 
     public function test_file_display_and_access()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'upload_test_file.txt';
 
@@ -105,30 +119,29 @@ 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', [
-            'link' => 'https://example.com',
-            'name' => 'Example Attachment Link',
-            'uploaded_to' => $page->id,
+            'attachment_link_url'         => 'https://example.com',
+            'attachment_link_name'        => 'Example Attachment Link',
+            'attachment_link_uploaded_to' => $page->id,
         ]);
 
-        $expectedResp = [
-            'path' => 'https://example.com',
-            'name' => 'Example Attachment Link',
+        $expectedData = [
+            '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);
-        $linkReq->assertJson($expectedResp);
-        $this->assertDatabaseHas('attachments', $expectedResp);
+        $this->assertDatabaseHas('attachments', $expectedData);
         $attachment = Attachment::orderBy('id', 'desc')->take(1)->first();
 
         $pageGet = $this->get($page->getUrl());
@@ -143,39 +156,31 @@ class AttachmentTest extends TestCase
 
     public function test_attachment_updating()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
 
-        $this->call('POST', 'attachments/link', [
-            'link' => 'https://example.com',
-            'name' => 'Example Attachment Link',
-            'uploaded_to' => $page->id,
+        $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',
         ]);
 
-        $attachmentId = \BookStack\Uploads\Attachment::first()->id;
-
-        $update = $this->call('PUT', 'attachments/' . $attachmentId, [
+        $expectedData = [
+            'id'          => $attachment->id,
+            'path'        => 'https://test.example.com',
+            'name'        => 'My new attachment name',
             'uploaded_to' => $page->id,
-            'name' => 'My new attachment name',
-            'link' => 'https://test.example.com'
-        ]);
-
-        $expectedResp = [
-            'path' => 'https://test.example.com',
-            'name' => 'My new attachment name',
-            'uploaded_to' => $page->id
         ];
 
         $update->assertStatus(200);
-        $update->assertJson($expectedResp);
-        $this->assertDatabaseHas('attachments', $expectedResp);
+        $this->assertDatabaseHas('attachments', $expectedData);
 
         $this->deleteUploads();
     }
 
     public function test_file_deletion()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'deletion_test.txt';
         $this->uploadFile($fileName, $page->id);
@@ -184,11 +189,11 @@ class AttachmentTest extends TestCase
         $filePath = storage_path($attachment->path);
         $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
 
-        $attachment = \BookStack\Uploads\Attachment::first();
+        $attachment = Attachment::first();
         $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');
 
@@ -197,7 +202,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);
@@ -207,13 +212,14 @@ class AttachmentTest extends TestCase
 
         $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
         $this->assertDatabaseHas('attachments', [
-            'name' => $fileName
+            'name' => $fileName,
         ]);
 
-        $this->call('DELETE', $page->getUrl());
+        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');
 
@@ -224,8 +230,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);
@@ -240,7 +245,66 @@ 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::query()->first();
+        $this->asAdmin();
+
+        $badLinks = [
+            'javascript:alert("bunny")',
+            ' 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>',
+        ];
+
+        foreach ($badLinks as $badLink) {
+            $linkReq = $this->post('attachments/link', [
+                'attachment_link_url'         => $badLink,
+                'attachment_link_name'        => 'Example Attachment Link',
+                'attachment_link_uploaded_to' => $page->id,
+            ]);
+            $linkReq->assertStatus(422);
+            $this->assertDatabaseMissing('attachments', [
+                'path' => $badLink,
+            ]);
+        }
+
+        $attachment = $this->createAttachment($page);
+
+        foreach ($badLinks as $badLink) {
+            $linkReq = $this->put('attachments/' . $attachment->id, [
+                'attachment_edit_url'  => $badLink,
+                'attachment_edit_name' => 'Example Attachment Link',
+            ]);
+            $linkReq->assertStatus(422);
+            $this->assertDatabaseMissing('attachments', [
+                'path' => $badLink,
+            ]);
+        }
+    }
+
+    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"');
 
         $this->deleteUploads();
     }
index ecf7037a9ffe281c1d2778ee49b128da2c3e261d..cf568d07cf6c909b7c46597b5a22d91bb25f94e2 100644 (file)
@@ -1,6 +1,9 @@
-<?php namespace Tests\Uploads;
+<?php
+
+namespace Tests\Uploads;
 
 use BookStack\Auth\User;
+use BookStack\Exceptions\HttpFetchException;
 use BookStack\Uploads\HttpFetcher;
 use Tests\TestCase;
 
@@ -8,22 +11,21 @@ class AvatarTest extends TestCase
 {
     use UsesImages;
 
-
     protected function createUserRequest($user)
     {
-        $resp = $this->asAdmin()->post('/settings/users/create', [
-            'name' => $user->name,
-            'email' => $user->email,
-            'password' => 'testing',
+        $this->asAdmin()->post('/settings/users/create', [
+            'name'             => $user->name,
+            'email'            => $user->email,
+            'password'         => 'testing',
             'password-confirm' => 'testing',
         ]);
+
         return User::where('email', '=', $user->email)->first();
     }
 
     protected function assertImageFetchFrom(string $url)
     {
-        $http = \Mockery::mock(HttpFetcher::class);
-        $this->app->instance(HttpFetcher::class, $http);
+        $http = $this->mock(HttpFetcher::class);
 
         $http->shouldReceive('fetch')
             ->once()->with($url)
@@ -41,25 +43,25 @@ class AvatarTest extends TestCase
             'services.disable_services' => false,
         ]);
         $user = factory(User::class)->make();
-        $this->assertImageFetchFrom('https://www.gravatar.com/avatar/'.md5(strtolower($user->email)).'?s=500&d=identicon');
+        $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.avatar_url' => 'https://example.com/${email}/${hash}/${size}',
+            'services.disable_services' => false,
+            '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';
+        $url = 'https://example.com/' . urlencode(strtolower($user->email)) . '/' . md5(strtolower($user->email)) . '/500';
         $this->assertImageFetchFrom($url);
 
         $user = $this->createUserRequest($user);
@@ -74,11 +76,25 @@ class AvatarTest extends TestCase
 
         $user = factory(User::class)->make();
 
-        $http = \Mockery::mock(HttpFetcher::class);
-        $this->app->instance(HttpFetcher::class, $http);
+        $http = $this->mock(HttpFetcher::class);
         $http->shouldNotReceive('fetch');
 
         $this->createUserRequest($user);
     }
 
+    public function test_no_failure_but_error_logged_on_failed_avatar_fetch()
+    {
+        config()->set([
+            'services.disable_services' => false,
+        ]);
+
+        $http = $this->mock(HttpFetcher::class);
+        $http->shouldReceive('fetch')->andThrow(new HttpFetchException());
+
+        $logger = $this->withTestLogger();
+
+        $user = factory(User::class)->make();
+        $this->createUserRequest($user);
+        $this->assertTrue($logger->hasError('Failed to save user avatar image'));
+    }
 }
index f940a0a5d9c9ee97c3f3e14808108c6efbe01260..422de472ae00d610dfb4d67e28b47cb157e59ac7 100644 (file)
@@ -1,6 +1,8 @@
-<?php namespace Tests\Uploads;
+<?php
 
-use BookStack\Entities\Page;
+namespace Tests\Uploads;
+
+use BookStack\Entities\Models\Page;
 use BookStack\Uploads\Image;
 use Tests\TestCase;
 
@@ -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()
@@ -69,11 +71,10 @@ class DrawioTest extends TestCase
         $editor = $this->getEditor();
 
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
-        $resp->assertSee('drawio-url="https://www.draw.io/?embed=1&amp;proto=json&amp;spin=1"');
+        $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&amp;proto=json&amp;spin=1"');
 
         config()->set('services.drawio', false);
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
         $resp->assertDontSee('drawio-url');
     }
-
-}
\ No newline at end of file
+}
index 416927ac93170bb1649429d7fdd354932befb6af..69b6dc90e96218296e84d51db5d788a7af5a7aa5 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\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));
@@ -71,17 +72,13 @@ class ImageTest extends TestCase
         $newName = Str::random();
         $update = $this->put('/images/' . $image->id, ['name' => $newName]);
         $update->assertSuccessful();
-        $update->assertJson([
-            'id' => $image->id,
-            'name' => $newName,
-            'type' => 'gallery',
-        ]);
+        $update->assertSee($newName);
 
         $this->deleteImage($imgDetails['path']);
 
         $this->assertDatabaseHas('images', [
             'type' => 'gallery',
-            'name' => $newName
+            'name' => $newName,
         ]);
     }
 
@@ -92,60 +89,47 @@ class ImageTest extends TestCase
         $imgDetails = $this->uploadGalleryImage();
         $image = Image::query()->first();
 
-        $emptyJson = ['images' => [], 'has_more' => false];
-        $resultJson = [
-            'images' => [
-                [
-                    'id' => $image->id,
-                    'name' => $imgDetails['name'],
-                ]
-            ],
-            'has_more' => false,
-        ];
-
         $pageId = $imgDetails['page']->id;
         $firstPageRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}");
-        $firstPageRequest->assertSuccessful()->assertJson($resultJson);
+        $firstPageRequest->assertSuccessful()->assertElementExists('div');
+        $firstPageRequest->assertSuccessful()->assertSeeText($image->name);
 
         $secondPageRequest = $this->get("/images/gallery?page=2&uploaded_to={$pageId}");
-        $secondPageRequest->assertSuccessful()->assertExactJson($emptyJson);
+        $secondPageRequest->assertSuccessful()->assertElementNotExists('div');
 
         $namePartial = substr($imgDetails['name'], 0, 3);
         $searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
-        $searchHitRequest->assertSuccessful()->assertJson($resultJson);
+        $searchHitRequest->assertSuccessful()->assertSee($imgDetails['name']);
 
         $namePartial = Str::random(16);
-        $searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
-        $searchHitRequest->assertSuccessful()->assertExactJson($emptyJson);
+        $searchFailRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
+        $searchFailRequest->assertSuccessful()->assertDontSee($imgDetails['name']);
+        $searchFailRequest->assertSuccessful()->assertElementNotExists('div');
     }
 
     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/usage/' . $image->id);
+        $usage = $this->get('/images/edit/' . $image->id . '?delete=true');
         $usage->assertSuccessful();
-        $usage->assertJson([
-            [
-                'id' => $page->id,
-                'name' => $page->name
-            ]
-        ]);
+        $usage->assertSeeText($page->name);
+        $usage->assertSee($page->getUrl());
 
         $this->deleteImage($imgDetails['path']);
     }
 
     public function test_php_files_cannot_be_uploaded()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
@@ -153,7 +137,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-php.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
@@ -161,13 +145,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);
 
@@ -175,28 +159,68 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-phtml.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
         $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded php file was uploaded but should have been stopped');
     }
 
-    public function test_files_with_double_extensions_cannot_be_uploaded()
+    public function test_files_with_double_extensions_will_get_sanitized()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
         $fileName = 'bad.phtml.png';
         $relPath = $this->getTestImagePath('gallery', $fileName);
-        $this->deleteImage($relPath);
+        $expectedRelPath = dirname($relPath) . '/bad-phtml.png';
+        $this->deleteImage($expectedRelPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-phtml-png.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/png')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
-        $upload->assertStatus(302);
+        $upload->assertStatus(200);
+
+        $lastImage = Image::query()->latest('id')->first();
 
-        $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded double extension file was uploaded but should have been stopped');
+        $this->assertEquals('bad.phtml.png', $lastImage->name);
+        $this->assertEquals('bad-phtml.png', basename($lastImage->path));
+        $this->assertFileDoesNotExist(public_path($relPath), 'Uploaded image file name was not stripped of dots');
+        $this->assertFileExists(public_path($expectedRelPath));
+
+        $this->deleteImage($lastImage->path);
+    }
+
+    public function test_url_entities_removed_from_filenames()
+    {
+        $this->asEditor();
+        $badNames = [
+            'bad-char-#-image.png',
+            'bad-char-?-image.png',
+            '?#.png',
+            '?.png',
+            '#.png',
+        ];
+        foreach ($badNames as $name) {
+            $galleryFile = $this->getTestImage($name);
+            $page = Page::query()->first();
+            $badPath = $this->getTestImagePath('gallery', $name);
+            $this->deleteImage($badPath);
+
+            $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
+            $upload->assertStatus(200);
+
+            $lastImage = Image::query()->latest('id')->first();
+            $newFileName = explode('.', basename($lastImage->path))[0];
+
+            $this->assertEquals($lastImage->name, $name);
+            $this->assertFalse(strpos($lastImage->path, $name), 'Path contains original image name');
+            $this->assertFalse(file_exists(public_path($badPath)), 'Uploaded image file name was not stripped of url entities');
+
+            $this->assertTrue(strlen($newFileName) > 0, 'File name was reduced to nothing');
+
+            $this->deleteImage($lastImage->path);
+        }
     }
 
     public function test_secure_images_uploads_to_correct_place()
@@ -204,13 +228,13 @@ 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);
@@ -222,8 +246,8 @@ 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], []);
         $imageUrl = json_decode($upload->getContent(), true)['url'];
@@ -245,12 +269,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);
@@ -259,25 +283,51 @@ 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);
+        $this->deleteImage($relPath);
 
         $this->uploadImage($imageName, $page->id);
         $image = Image::first();
-        $relPath = $this->getTestImagePath('gallery', $imageName);
 
-        $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');
     }
 
+    public function test_image_delete_does_not_delete_similar_images()
+    {
+        $page = Page::query()->first();
+        $this->asAdmin();
+        $imageName = 'first-image.png';
+
+        $relPath = $this->getTestImagePath('gallery', $imageName);
+        $this->deleteImage($relPath);
+
+        $this->uploadImage($imageName, $page->id);
+        $this->uploadImage($imageName, $page->id);
+        $this->uploadImage($imageName, $page->id);
+
+        $image = Image::first();
+        $folder = public_path(dirname($relPath));
+        $imageCount = count(glob($folder . '/*'));
+
+        $delete = $this->delete('/images/' . $image->id);
+        $delete->assertStatus(200);
+
+        $newCount = count(glob($folder . '/*'));
+        $this->assertEquals($imageCount - 1, $newCount, 'More files than expected have been deleted');
+        $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected');
+    }
+
     protected function getTestProfileImage()
     {
         $imageName = 'profile.png';
@@ -297,9 +347,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,
         ]);
     }
 
@@ -312,7 +362,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));
@@ -321,12 +371,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));
@@ -334,7 +384,7 @@ class ImageTest extends TestCase
 
     public function test_deleted_unused_images()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->actingAs($admin);
 
@@ -348,9 +398,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
@@ -360,9 +410,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
@@ -386,5 +436,4 @@ class ImageTest extends TestCase
 
         $this->deleteImage($relPath);
     }
-
-}
\ No newline at end of file
+}
index 251a61c9f5841c2ad9513fbfc7140594b3adb4fc..789c967c6771c6938bf0f51491c9b6ee5677b243 100644 (file)
@@ -1,15 +1,16 @@
-<?php namespace Tests\Uploads;
+<?php
 
-use BookStack\Entities\Page;
+namespace Tests\Uploads;
+
+use BookStack\Entities\Models\Page;
 use Illuminate\Http\UploadedFile;
 
 trait UsesImages
 {
     /**
-     * Get the path to our basic test image.
-     * @return string
+     * Get the path to a file in the test-data-directory.
      */
-    protected function getTestImageFilePath(?string $fileName = null)
+    protected function getTestImageFilePath(?string $fileName = null): string
     {
         if (is_null($fileName)) {
             $fileName = 'test-image.png';
@@ -19,17 +20,32 @@ trait UsesImages
     }
 
     /**
-     * Get a test image that can be uploaded
-     * @param $fileName
-     * @return UploadedFile
+     * Creates a new temporary image file using the given name,
+     * with the content decoded from the given bas64 file name.
+     * Is generally used for testing sketchy files that could trip AV.
+     */
+    protected function newTestImageFromBase64(string $base64FileName, $imageFileName): UploadedFile
+    {
+        $imagePath = implode(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), $imageFileName]);
+        $base64FilePath = $this->getTestImageFilePath($base64FileName);
+        $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.
      */
-    protected function getTestImage($fileName, ?string $testDataFileName = null)
+    protected function getTestImage(string $fileName, ?string $testDataFileName = null): UploadedFile
     {
-        return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', 5238, null, true);
+        return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', null, true);
     }
 
     /**
      * Get the raw file data for the test image.
+     *
      * @return false|string
      */
     protected function getTestImageContent()
@@ -39,25 +55,25 @@ trait UsesImages
 
     /**
      * Get the path for a test image.
-     * @param $type
-     * @param $fileName
-     * @return string
      */
-    protected function getTestImagePath($type, $fileName)
+    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], []);
     }
@@ -66,7 +82,9 @@ 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
      */
     protected function uploadGalleryImage(Page $page = null, ?string $testDataFileName = null)
@@ -81,24 +99,23 @@ 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()),
         ];
     }
 
     /**
      * Delete an uploaded image.
-     * @param $relPath
      */
-    protected function deleteImage($relPath)
+    protected function deleteImage(string $relPath)
     {
         $path = public_path($relPath);
         if (file_exists($path)) {
             unlink($path);
         }
     }
-
-}
\ No newline at end of file
+}
index f738eb579e4f9a836bc7f818e7de39e59a78ace9..d3404b72ef14a11e21af96408d190b2d88f544f3 100644 (file)
@@ -1,14 +1,16 @@
-<?php namespace Test\User;
+<?php
 
+namespace Tests\User;
+
+use BookStack\Actions\ActivityType;
 use BookStack\Api\ApiToken;
 use Carbon\Carbon;
 use Tests\TestCase;
 
 class UserApiTokenTest extends TestCase
 {
-
     protected $testTokenData = [
-        'name' => 'My test API token',
+        'name'       => 'My test API token',
         'expires_at' => '2050-04-01',
     ];
 
@@ -50,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'],
         ]);
 
@@ -67,6 +69,7 @@ class UserApiTokenTest extends TestCase
         $this->assertTrue(strlen($secret) === 32);
 
         $this->assertSessionHas('success');
+        $this->assertActivityExists(ActivityType::API_TOKEN_CREATE);
     }
 
     public function test_create_with_no_expiry_sets_expiry_hundred_years_away()
@@ -79,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'
         );
     }
 
@@ -115,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',
         ];
 
@@ -124,6 +127,7 @@ class UserApiTokenTest extends TestCase
 
         $this->assertDatabaseHas('api_tokens', array_merge($updateData, ['id' => $token->id]));
         $this->assertSessionHas('success');
+        $this->assertActivityExists(ActivityType::API_TOKEN_UPDATE);
     }
 
     public function test_token_update_with_blank_expiry_sets_to_hundred_years_away()
@@ -133,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();
@@ -142,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'
         );
     }
 
@@ -157,11 +161,12 @@ 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'));
         $this->assertDatabaseMissing('api_tokens', ['id' => $token->id]);
+        $this->assertActivityExists(ActivityType::API_TOKEN_DELETE);
     }
 
     public function test_user_manage_can_delete_token_without_api_permission_themselves()
@@ -181,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
+}
diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php
new file mode 100644 (file)
index 0000000..ed2fb5f
--- /dev/null
@@ -0,0 +1,167 @@
+<?php
+
+namespace Tests\User;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use Tests\TestCase;
+
+class UserManagementTest extends TestCase
+{
+    public function test_user_creation()
+    {
+        /** @var User $user */
+        $user = factory(User::class)->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 = $this->followRedirects($resp);
+
+        $resp->assertSee('User successfully removed');
+        $this->assertActivityExists(ActivityType::USER_DELETE);
+
+        $this->assertDatabaseMissing('users', ['id' => $editor->id]);
+    }
+
+    public function test_delete_offers_migrate_option()
+    {
+        $editor = $this->getEditor();
+        $resp = $this->asAdmin()->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();
+
+        $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]);
+        $this->assertDatabaseHas('pages', [
+            'id'       => $page->id,
+            'owned_by' => $newOwner->id,
+        ]);
+    }
+
+    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');
+    }
+}
index 0db4f803aff0bf268f4004b2339280dba10cc3fd..b39c2c47c84bee8145b0340baade7a183d5bdac9 100644 (file)
@@ -1,28 +1,30 @@
-<?php namespace Test\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);
     }
 
@@ -92,4 +94,57 @@ class UserPreferencesTest extends TestCase
         $home->assertDontSee('Dark Mode');
         $home->assertSee('Light Mode');
     }
-}
\ No newline at end of file
+
+    public function test_dark_mode_defaults_to_config_option()
+    {
+        config()->set('setting-defaults.user.dark-mode-enabled', false);
+        $this->assertEquals(false, setting()->getForCurrentUser('dark-mode-enabled'));
+        $home = $this->get('/login');
+        $home->assertElementNotExists('.dark-mode');
+
+        config()->set('setting-defaults.user.dark-mode-enabled', true);
+        $this->assertEquals(true, setting()->getForCurrentUser('dark-mode-enabled'));
+        $home = $this->get('/login');
+        $home->assertElementExists('.dark-mode');
+    }
+
+    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 0a3a1a6b202fcf776457f9478ddba01e39cbd76b..3942efa8e3d095a1d1084594d4c0d9959e91fde9 100644 (file)
@@ -1,12 +1,17 @@
-<?php namespace Test\User;
+<?php
+
+namespace Tests\User;
 
 use Activity;
+use BookStack\Actions\ActivityType;
 use BookStack\Auth\User;
-use BookStack\Entities\Bookshelf;
-use Tests\BrowserKitTest;
+use Tests\TestCase;
 
-class UserProfileTest extends BrowserKitTest
+class UserProfileTest extends TestCase
 {
+    /**
+     * @var User
+     */
     protected $user;
 
     public function setUp(): void
@@ -18,127 +23,83 @@ class UserProfileTest extends BrowserKitTest
     public function test_profile_page_shows_name()
     {
         $this->asAdmin()
-            ->visit('/user/' . $this->user->id)
-            ->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->id)
-            // 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 = factory(User::class)->create();
 
-        $this->asAdmin()->visit('/user/' . $newUser->id)
-            ->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->id)
-            ->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 = factory(User::class)->create();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
-        Activity::add($entities['book'], 'book_update', $entities['book']->id);
-        Activity::add($entities['page'], 'page_create', $entities['book']->id);
+        Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
+        Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
 
-        $this->asAdmin()->visit('/user/' . $newUser->id)
-            ->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 = factory(User::class)->create();
         $this->actingAs($newUser);
         $entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
-        Activity::add($entities['book'], 'book_update', $entities['book']->id);
-        Activity::add($entities['page'], 'page_create', $entities['book']->id);
-
-        $this->asAdmin()->visit('/')->clickInElement('#recent-activity', $newUser->name)
-            ->seePageIs('/user/' . $newUser->id)
-            ->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::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
+        Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
 
-    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/test-data/bad-php.base64 b/tests/test-data/bad-php.base64
new file mode 100644 (file)
index 0000000..550ce17
--- /dev/null
@@ -0,0 +1,10 @@
+/9j/4AAQSkZJRgABAQEBLAEsAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQH/wgARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQBAQAA
+AAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADEAAAAT/n/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgB
+AQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k8P3BocCBlY2hvICdiYWRwaHAnOwo=
diff --git a/tests/test-data/bad-phtml-png.base64 b/tests/test-data/bad-phtml-png.base64
new file mode 100644 (file)
index 0000000..7fd9d8f
--- /dev/null
@@ -0,0 +1,3 @@
+iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
+B3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUH
+AAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=
diff --git a/tests/test-data/bad-phtml.base64 b/tests/test-data/bad-phtml.base64
new file mode 100644 (file)
index 0000000..550ce17
--- /dev/null
@@ -0,0 +1,10 @@
+/9j/4AAQSkZJRgABAQEBLAEsAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQH/wgARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQBAQAA
+AAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADEAAAAT/n/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgB
+AQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k8P3BocCBlY2hvICdiYWRwaHAnOwo=
diff --git a/tests/test-data/bad.php b/tests/test-data/bad.php
deleted file mode 100644 (file)
index 3b7c0f3..0000000
Binary files a/tests/test-data/bad.php and /dev/null differ
diff --git a/tests/test-data/bad.phtml b/tests/test-data/bad.phtml
deleted file mode 100644 (file)
index 3b7c0f3..0000000
Binary files a/tests/test-data/bad.phtml and /dev/null differ
diff --git a/tests/test-data/bad.phtml.png b/tests/test-data/bad.phtml.png
deleted file mode 100644 (file)
index dd15f6e..0000000
Binary files a/tests/test-data/bad.phtml.png and /dev/null differ
diff --git a/version b/version
index 31c2e1d4bb4cfcb6f9e5c7e394aa225b0664fd2e..0d86fac788e718d59afcf33dbc4b497fa1cbe152 100644 (file)
--- a/version
+++ b/version
@@ -1 +1 @@
-v0.30-dev
+v21.06-dev
diff --git a/webpack.config.js b/webpack.config.js
deleted file mode 100644 (file)
index 503b2cb..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-const path = require('path');
-const dev = process.env.NODE_ENV !== 'production';
-
-const config = {
-    target: 'web',
-    mode: dev? 'development' : 'production',
-    entry: {
-        app: './resources/js/index.js',
-    },
-    output: {
-        filename: '[name].js',
-        path: path.resolve(__dirname, 'public/dist')
-    },
-};
-
-if (dev) {
-    config['devtool'] = 'inline-source-map';
-}
-
-module.exports = config;
\ No newline at end of file